feat: integrate ReUI components and update MCP configuration
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Added ReUI configuration to .mcp.json and .cursor/mcp.json for component integration.
- Updated pnpm-lock.yaml with new dependencies including react-phone-number-input and adjustments to existing packages.
- Enhanced SKILL.md documentation for ReUI to clarify usage and features.
- Removed unused components (ChartCard, DataGridCard, etc.) to streamline the codebase.
- Adjusted domain-related components and filters for improved functionality and UI consistency.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-10 00:37:40 +07:00
co-authored by Cursor
parent 59163bfab5
commit 29e57666e0
225 changed files with 21908 additions and 4476 deletions
@@ -0,0 +1,112 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
export interface DetailMetricCard {
id: string
icon: ReactNode
label: string
description: string
footer?: ReactNode
}
interface DetailPanelProps {
children: ReactNode
className?: string
}
function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
interface DetailPanelHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
function DetailPanelHeader({
title,
description,
actions,
children,
}: DetailPanelHeaderProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</FrameHeader>
{children ? <FramePanel className="space-y-4">{children}</FramePanel> : null}
</Frame>
)
}
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="space-y-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
function DetailPanelSection({
title,
description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-3">
{title ? (
<header className="px-1">
<h2 className="text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
) : null}
{children}
</section>
)
}
export const DetailPanel = Object.assign(DetailPanelRoot, {
Header: DetailPanelHeader,
Metrics: DetailPanelMetrics,
Section: DetailPanelSection,
})
@@ -0,0 +1,91 @@
import type { Filter } from '@/components/reui/filters'
export function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
export function applyFiltersToData<T>(
data: T[],
filters: Filter[],
getFieldValue: (item: T, field: string) => unknown,
): T[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((value) => fieldValue === value)
case 'is_not_any_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
}
return result
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
export function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[],
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
@@ -0,0 +1,6 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export { OpsDashboard, OpsDashboardHintLink, type OpsKpiCard } from './ops-dashboard'
export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -0,0 +1,277 @@
import { useMemo, type ComponentProps, type ReactNode } from 'react'
import {
Frame,
FrameHeader,
FrameTitle,
} from '@/components/reui/frame'
import {
Kanban,
KanbanBoard as KanbanBoardPrimitive,
KanbanColumn,
KanbanColumnContent,
KanbanColumnHandle,
KanbanItem,
KanbanItemHandle,
KanbanOverlay,
} from '@/components/reui/kanban'
import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'
import { GripVerticalIcon, PlusIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import { Button } from '@cfdm/ui/components/button'
export interface KanbanColumnConfig {
id: string
title: string
description?: string
dotClassName?: string
addLabel?: string
onAdd?: () => void
}
export interface KanbanBoardProps<T extends { id: string | number }> {
title: string
description?: string
columns: KanbanColumnConfig[]
value: Record<string, T[]>
onValueChange: (value: Record<string, T[]>) => void
renderCard: (item: T, ctx: { isOverlay?: boolean }) => ReactNode
toolbarActions?: ReactNode
emptyColumnAction?: (column: KanbanColumnConfig) => ReactNode
}
function BoardScrollArea({ children }: { children: ReactNode }) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className="relative w-full min-w-0 pb-3"
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 w-full rounded-lg transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
<ScrollAreaPrimitive.Content
data-slot="scroll-area-content"
className="w-max min-w-full"
>
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="bg-foreground/15 relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
const COLUMN_HEADER_ACTION_BUTTON_CLASSNAME =
'text-muted-foreground hover:border-border! hover:bg-background! hover:text-foreground border border-transparent bg-transparent'
interface KanbanColumnViewProps<T extends { id: string | number }>
extends Omit<ComponentProps<typeof KanbanColumn>, 'value' | 'children'> {
column: KanbanColumnConfig
items: T[]
renderCard: (item: T, ctx: { isOverlay?: boolean }) => ReactNode
isOverlay?: boolean
emptyColumnAction?: (column: KanbanColumnConfig) => ReactNode
}
function KanbanColumnView<T extends { id: string | number }>({
column,
items,
renderCard,
isOverlay,
emptyColumnAction,
...props
}: KanbanColumnViewProps<T>) {
return (
<KanbanColumn
value={column.id}
className="w-[calc(100vw-3rem)] max-w-[19rem] shrink-0 sm:w-[19rem]"
{...props}
>
<Frame
spacing="sm"
className={cn('group/column', isOverlay && 'shadow-lg')}
aria-label={column.description ?? column.title}
>
<FrameHeader className="flex min-h-10 flex-row items-center gap-2 px-2 py-1.5">
{column.dotClassName ? (
<span
className={cn('size-2.5 shrink-0 rounded-full', column.dotClassName)}
aria-hidden="true"
/>
) : null}
<FrameTitle className="truncate text-sm leading-5" title={column.title}>
{column.title}
</FrameTitle>
<span className="text-muted-foreground shrink-0 text-sm font-medium tabular-nums">
{items.length}
</span>
{!isOverlay ? (
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/column:opacity-100 group-hover/column:opacity-100">
{column.onAdd ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className={COLUMN_HEADER_ACTION_BUTTON_CLASSNAME}
aria-label={column.addLabel ?? 'Добавить'}
title={column.addLabel ?? 'Добавить'}
onClick={column.onAdd}
>
<PlusIcon aria-hidden="true" />
</Button>
) : null}
<KanbanColumnHandle
className="group-focus-within/column:opacity-100"
render={({ className, ...handleProps }) => (
<Button
{...handleProps}
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Переместить колонку ${column.title}`}
title={`Переместить колонку ${column.title}`}
className={cn(COLUMN_HEADER_ACTION_BUTTON_CLASSNAME, className)}
>
<GripVerticalIcon aria-hidden="true" />
</Button>
)}
/>
</div>
) : null}
</FrameHeader>
<KanbanColumnContent value={column.id} className="gap-2 p-0.5">
{items.map((item) => (
<KanbanItem key={String(item.id)} value={String(item.id)}>
<KanbanItemHandle className="block">
{renderCard(item, { isOverlay: false })}
</KanbanItemHandle>
</KanbanItem>
))}
{items.length === 0
? (emptyColumnAction?.(column) ?? (
<Button
type="button"
variant="outline"
className="text-muted-foreground hover:text-foreground bg-background/70 h-20 w-full border-dashed text-sm"
aria-label={column.addLabel ?? 'Пусто'}
disabled={!column.onAdd}
onClick={column.onAdd}
>
{column.addLabel ?? 'Пусто'}
</Button>
))
: null}
</KanbanColumnContent>
</Frame>
</KanbanColumn>
)
}
function findItem<T extends { id: string | number }>(
columns: Record<string, T[]>,
itemId: string,
): T | null {
for (const items of Object.values(columns)) {
const found = items.find((item) => String(item.id) === itemId)
if (found) return found
}
return null
}
export function KanbanBoard<T extends { id: string | number }>({
title,
description,
columns: columnConfigs,
value,
onValueChange,
renderCard,
toolbarActions,
emptyColumnAction,
}: KanbanBoardProps<T>) {
const columnById = useMemo(
() => new Map(columnConfigs.map((c) => [c.id, c])),
[columnConfigs],
)
return (
<section className="mx-auto flex w-full flex-col gap-4">
<header className="px-1 py-1" aria-label={title}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<h2 className="truncate text-lg leading-7 font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 max-w-[52ch] text-sm leading-5">
{description}
</p>
) : null}
</div>
{toolbarActions ? (
<div
className="flex w-full flex-wrap items-center gap-2 lg:w-auto lg:justify-end"
role="group"
>
{toolbarActions}
</div>
) : null}
</div>
</header>
<Kanban
value={value}
onValueChange={onValueChange}
getItemValue={(item) => String(item.id)}
className="w-full"
>
<BoardScrollArea>
<KanbanBoardPrimitive className="grid min-w-max auto-cols-[19rem] grid-flow-col grid-cols-none items-start gap-3 p-1">
{Object.entries(value).map(([columnId, items]) => {
const column = columnById.get(columnId)
if (!column) return null
return (
<KanbanColumnView
key={columnId}
column={column}
items={items}
renderCard={renderCard}
emptyColumnAction={emptyColumnAction}
/>
)
})}
</KanbanBoardPrimitive>
</BoardScrollArea>
<KanbanOverlay>
{({ value: overlayValue, variant }) => {
if (variant === 'column') {
const column = columnById.get(String(overlayValue))
if (!column) return null
return (
<KanbanColumnView
column={column}
items={value[column.id] ?? []}
renderCard={renderCard}
isOverlay
/>
)
}
const item = findItem(value, String(overlayValue))
return item ? renderCard(item, { isOverlay: true }) : null
}}
</KanbanOverlay>
</Kanban>
</section>
)
}
@@ -0,0 +1,135 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
export interface OpsKpiCard {
id: string
typeLabel: string
title: string
metricLabel: string
value: string | number
hint?: ReactNode
icon: ReactNode
iconBg?: string
}
interface OpsDashboardProps {
title?: string
description?: string
kpiCards: OpsKpiCard[]
charts: ReactNode
queue: ReactNode
}
function KpiCardItem({ card }: { card: OpsKpiCard }) {
return (
<FramePanel>
<div className="flex items-center gap-2.5">
<Item
className={cn(
'p-0',
'border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white',
card.iconBg ?? 'bg-chart-1',
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="flex min-w-0 flex-col gap-1">
<p className="text-muted-foreground text-sm leading-tight">
{card.typeLabel}
</p>
<h3 className="truncate text-sm leading-tight font-medium">{card.title}</h3>
</div>
</div>
<div className="mt-5 space-y-1.5">
<p className="text-muted-foreground text-sm leading-tight">
{card.metricLabel}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xl font-medium tracking-tight tabular-nums">
{card.value}
</span>
{card.hint}
</div>
</div>
</FramePanel>
)
}
export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
kpiCards,
charts,
queue,
}: OpsDashboardProps) {
return (
<div className="text-foreground @container mx-auto flex w-full flex-col gap-4 md:gap-6">
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
</header>
<section aria-label="Ключевые метрики">
<Frame className="@container w-full">
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
{kpiCards.map((card) => (
<KpiCardItem key={card.id} card={card} />
))}
</div>
</Frame>
</section>
<section
aria-label="Аналитика"
className="grid min-w-0 items-stretch gap-4 @5xl:grid-cols-2"
>
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Истекающие сертификаты и домены без группы
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
</div>
)
}
export function OpsDashboardHintLink({
to,
search,
children,
}: {
to: string
search?: Record<string, unknown>
children: ReactNode
}) {
return (
<Link
to={to}
search={search}
className="text-primary text-sm font-medium hover:underline"
>
{children}
</Link>
)
}
@@ -0,0 +1,343 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { FilterIcon, FunnelXIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab {
id: string
label: string
count?: number
}
export interface ResourcePageProps<T extends object> {
title: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[]
data: T[]
getRowId: (row: T) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
toolbarExtra?: ReactNode
hideHeader?: boolean
}
function ResourcePageSkeleton() {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</FramePanel>
</Frame>
)
}
export function ResourcePage<T extends object>({
title,
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
emptyState,
pageSize = 10,
enableRowSelection = false,
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
)
}, [])
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = applyFiltersToData(data, filters, getFilterFieldValue)
const counts: Record<string, number> = {}
for (const tab of tabs) {
counts[tab.id] =
tab.id === 'all'
? base.length
: base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
const selectedCount = useMemo(
() => Object.keys(rowSelection).length,
[rowSelection],
)
const table = useReactTable({
data: filteredData,
columns,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleTabChange = useCallback(
(value: string) => {
if (onTabChange) onTabChange(value)
else setInternalTab(value)
resetPagination()
},
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: Filter[]) => {
onFiltersChange(next)
resetPagination()
},
[onFiltersChange, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
resetPagination()
}, [onClearFilters, resetPagination])
if (isLoading) {
return <ResourcePageSkeleton />
}
if (isError) {
return (
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
if (data.length === 0 && emptyState) {
return (
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
return (
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={emptyMessage}
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
{primaryAction ? (
<div className="shrink-0">{primaryAction}</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
{tabs && tabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList variant="line" className="gap-5">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="gap-2 px-0 pb-3 text-sm"
>
<span>{tab.label}</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tabCounts[tab.id] ?? tab.count ?? 0}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FunnelXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)
}
@@ -0,0 +1,101 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import { PageShell } from '@/components/page-shell'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
children?: ReactNode
}
export function SettingsShell({
title = 'Настройки',
description = 'Интеграции и конфигурация приложения',
tabs = DEFAULT_TABS,
children,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeTab =
tabs.find((tab) => pathname.startsWith(tab.to))?.id ?? tabs[0]?.id ?? ''
return (
<PageShell>
<div className="w-full max-w-4xl space-y-6">
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
</header>
<Tabs value={activeTab} orientation={isMobile ? 'horizontal' : 'vertical'}>
<div
className={cn(
'flex gap-6',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
<TabsList
variant="line"
className={cn(
'h-auto w-full justify-start gap-1 bg-transparent p-0',
!isMobile && 'w-48 shrink-0 flex-col items-stretch',
)}
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className={cn(
'justify-start gap-2 px-3 py-2',
!isMobile && 'w-full',
)}
render={<Link to={tab.to} />}
>
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="min-w-0 flex-1">
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-0">
{children ?? <Outlet />}
</TabsContent>
))}
</div>
</div>
</Tabs>
</div>
</PageShell>
)
}