quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 1m44s
Боковое меню, шапка, монитор системы и палитра Ctrl+K переведены на общие блоки макета, данные из живых запросов (агенты, списки, наборы правил). KPI стали полосой в одном Frame, таблицы ResourcePage получили тулбар как в MikrotikManager (сегменты, фильтры, поиск, счётчик), подложку шапки и ячейки py-3. Старая оболочка удалена. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from 'react'
|
||
import {
|
||
getCoreRowModel,
|
||
getPaginationRowModel,
|
||
getSortedRowModel,
|
||
useReactTable,
|
||
type ColumnDef,
|
||
type ColumnPinningState,
|
||
type PaginationState,
|
||
type RowSelectionState,
|
||
type SortingState,
|
||
} from '@tanstack/react-table'
|
||
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
|
||
|
||
import { SegmentedControl } from '@/components/portal/data-page'
|
||
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,
|
||
type FilterI18nConfig,
|
||
} from '@/components/reui/filters'
|
||
import {
|
||
Frame,
|
||
FrameDescription,
|
||
FrameFooter,
|
||
FrameHeader,
|
||
FramePanel,
|
||
FrameTitle,
|
||
} from '@/components/reui/frame'
|
||
import { Button } from '@evofw/ui/components/button'
|
||
import {
|
||
InputGroup,
|
||
InputGroupAddon,
|
||
InputGroupInput,
|
||
} from '@evofw/ui/components/input-group'
|
||
import { Separator } from '@evofw/ui/components/separator'
|
||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||
import {
|
||
Alert,
|
||
AlertDescription,
|
||
AlertTitle,
|
||
} from '@/components/reui/alert'
|
||
import { EmptyState } from '@/components/empty-state'
|
||
import { applyFiltersToData } from './filter-utils'
|
||
|
||
type DataGridTableLayout = NonNullable<
|
||
ComponentProps<typeof DataGrid>['tableLayout']
|
||
>
|
||
|
||
/** Русская локаль фильтров (DNA data-grid-filtering-2, i18n по контракту). */
|
||
const FILTERS_I18N: Partial<FilterI18nConfig> = {
|
||
addFilter: 'Фильтр',
|
||
searchFields: 'Фильтр…',
|
||
noFieldsFound: 'Фильтры не найдены.',
|
||
noResultsFound: 'Ничего не найдено.',
|
||
select: 'Выберите…',
|
||
true: 'Да',
|
||
false: 'Нет',
|
||
min: 'От',
|
||
max: 'До',
|
||
to: '—',
|
||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||
selected: 'выбрано',
|
||
selectedCount: 'выбрано',
|
||
addFilterTitle: 'Добавить фильтр',
|
||
loadingOptions: 'Загрузка…',
|
||
errorLoadingOptions: 'Не удалось загрузить варианты.',
|
||
}
|
||
|
||
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 }
|
||
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||
filteredEmptyMessage?: string
|
||
pageSize?: number
|
||
enableRowSelection?: boolean
|
||
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||
rowSelection?: RowSelectionState
|
||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||
selectionToolbar?: (ctx: {
|
||
selectedIds: string[]
|
||
selectedCount: number
|
||
clearSelection: () => void
|
||
}) => ReactNode
|
||
toolbarExtra?: ReactNode
|
||
hideHeader?: boolean
|
||
onRowClick?: (row: T) => void
|
||
/** Toolbar search — DNA data-grid-filtering-2 */
|
||
searchQuery?: string
|
||
onSearchChange?: (query: string) => void
|
||
searchPlaceholder?: string
|
||
getSearchText?: (item: T) => string
|
||
/** Merge over defaults: dense + headerSticky + width fixed */
|
||
tableLayout?: Partial<DataGridTableLayout>
|
||
/** Pin columns (e.g. `{ right: ['actions'] }`) — enables column pinning */
|
||
columnPinning?: ColumnPinningState
|
||
}
|
||
|
||
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,
|
||
filteredEmptyMessage,
|
||
pageSize = 10,
|
||
enableRowSelection = false,
|
||
rowSelection: rowSelectionProp,
|
||
onRowSelectionChange: onRowSelectionChangeProp,
|
||
selectionToolbar,
|
||
toolbarExtra,
|
||
hideHeader = false,
|
||
onRowClick,
|
||
searchQuery = '',
|
||
onSearchChange,
|
||
searchPlaceholder = 'Поиск…',
|
||
getSearchText,
|
||
tableLayout: tableLayoutProp,
|
||
columnPinning: columnPinningProp,
|
||
}: ResourcePageProps<T>) {
|
||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||
const activeTab = controlledTab ?? internalTab
|
||
|
||
const [sorting, setSorting] = useState<SortingState>([])
|
||
const isSelectionControlled = rowSelectionProp !== undefined
|
||
const [internalRowSelection, setInternalRowSelection] =
|
||
useState<RowSelectionState>({})
|
||
const rowSelection = isSelectionControlled
|
||
? rowSelectionProp
|
||
: internalRowSelection
|
||
const handleRowSelectionChange = useCallback(
|
||
(
|
||
updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState),
|
||
) => {
|
||
const current = isSelectionControlled
|
||
? rowSelectionProp
|
||
: internalRowSelection
|
||
const next = typeof updater === 'function' ? updater(current) : updater
|
||
if (isSelectionControlled) onRowSelectionChangeProp?.(next)
|
||
else setInternalRowSelection(next)
|
||
},
|
||
[
|
||
isSelectionControlled,
|
||
rowSelectionProp,
|
||
internalRowSelection,
|
||
onRowSelectionChangeProp,
|
||
],
|
||
)
|
||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
||
() => columnPinningProp ?? {},
|
||
)
|
||
const [pagination, setPagination] = useState<PaginationState>({
|
||
pageIndex: 0,
|
||
pageSize,
|
||
})
|
||
|
||
const resetPagination = useCallback(() => {
|
||
setPagination((current) =>
|
||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
|
||
)
|
||
}, [])
|
||
|
||
const applySearch = useCallback(
|
||
(items: T[]) => {
|
||
const q = searchQuery.trim().toLowerCase()
|
||
if (!q || !getSearchText) return items
|
||
return items.filter((item) =>
|
||
getSearchText(item).toLowerCase().includes(q),
|
||
)
|
||
},
|
||
[searchQuery, getSearchText],
|
||
)
|
||
|
||
const filteredData = useMemo(() => {
|
||
let result = applySearch(data)
|
||
result = applyFiltersToData(result, filters, getFilterFieldValue)
|
||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||
result = result.filter((item) => tabFilter(item, activeTab))
|
||
}
|
||
return result
|
||
}, [
|
||
data,
|
||
applySearch,
|
||
filters,
|
||
getFilterFieldValue,
|
||
tabs,
|
||
tabFilter,
|
||
activeTab,
|
||
])
|
||
|
||
const tabCounts = useMemo(() => {
|
||
if (!tabs?.length || !tabFilter) return {}
|
||
const base = applyFiltersToData(
|
||
applySearch(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, applySearch, filters, getFilterFieldValue])
|
||
|
||
const selectedIds = useMemo(
|
||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||
[rowSelection],
|
||
)
|
||
|
||
const selectedCount = selectedIds.length
|
||
|
||
const clearSelection = useCallback(() => {
|
||
handleRowSelectionChange({})
|
||
}, [handleRowSelectionChange])
|
||
|
||
const tableLayout = useMemo(
|
||
() => ({
|
||
dense: false,
|
||
headerSticky: true,
|
||
headerBackground: true,
|
||
rowBorder: true,
|
||
width: 'fixed' as const,
|
||
...tableLayoutProp,
|
||
...(columnPinningProp || Object.keys(columnPinning).length > 0
|
||
? { columnsPinnable: true }
|
||
: {}),
|
||
}),
|
||
[tableLayoutProp, columnPinningProp, columnPinning],
|
||
)
|
||
|
||
const table = useReactTable({
|
||
data: filteredData,
|
||
columns,
|
||
getRowId,
|
||
state: { sorting, rowSelection, pagination, columnPinning },
|
||
enableRowSelection,
|
||
enableColumnPinning: Boolean(
|
||
tableLayout.columnsPinnable || columnPinningProp,
|
||
),
|
||
// tableLayout.columnsResizable включает fill-колонку (лишняя ширина),
|
||
// без drag-handles — enableColumnResizing остаётся false.
|
||
enableColumnResizing: false,
|
||
onSortingChange: setSorting,
|
||
onRowSelectionChange: handleRowSelectionChange,
|
||
onPaginationChange: setPagination,
|
||
onColumnPinningChange: setColumnPinning,
|
||
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?.()
|
||
onSearchChange?.('')
|
||
resetPagination()
|
||
}, [onClearFilters, onSearchChange, resetPagination])
|
||
|
||
const handleSearchChange = useCallback(
|
||
(value: string) => {
|
||
onSearchChange?.(value)
|
||
resetPagination()
|
||
},
|
||
[onSearchChange, resetPagination],
|
||
)
|
||
|
||
const showSearch = Boolean(onSearchChange && getSearchText)
|
||
const showClear = Boolean(onClearFilters || (showSearch && searchQuery))
|
||
|
||
const countedTabs = useMemo(
|
||
() =>
|
||
(tabs ?? []).map((tab) => ({
|
||
id: tab.id,
|
||
label: tab.label,
|
||
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||
})),
|
||
[tabs, tabCounts],
|
||
)
|
||
|
||
if (isLoading) {
|
||
return <ResourcePageSkeleton />
|
||
}
|
||
|
||
if (isError) {
|
||
return (
|
||
<Alert variant="destructive">
|
||
<CircleAlertIcon />
|
||
<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 (
|
||
<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="text-xs text-pretty">
|
||
{description}
|
||
</FrameDescription>
|
||
) : null}
|
||
</div>
|
||
{primaryAction ? (
|
||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||
{primaryAction}
|
||
</div>
|
||
) : null}
|
||
</FrameHeader>
|
||
) : null}
|
||
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
|
||
<EmptyState
|
||
title={emptyState.title}
|
||
description={emptyState.description}
|
||
action={emptyState.action}
|
||
/>
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|
||
|
||
const emptyMessage =
|
||
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||
|
||
return (
|
||
<div className="w-full">
|
||
{selectionToolbar && selectedCount > 0
|
||
? selectionToolbar({
|
||
selectedIds,
|
||
selectedCount,
|
||
clearSelection,
|
||
})
|
||
: null}
|
||
<DataGrid
|
||
table={table}
|
||
recordCount={filteredData.length}
|
||
emptyMessage={emptyMessage}
|
||
tableLayout={tableLayout}
|
||
onRowClick={onRowClick}
|
||
>
|
||
<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="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||
{primaryAction}
|
||
</div>
|
||
) : null}
|
||
</FrameHeader>
|
||
) : null}
|
||
|
||
<FramePanel className="p-0 shadow-none!">
|
||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||
{countedTabs.length > 0 ? (
|
||
<SegmentedControl
|
||
value={activeTab}
|
||
onChange={handleTabChange}
|
||
label="Фильтр по статусу"
|
||
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
|
||
/>
|
||
) : null}
|
||
<Filters
|
||
filters={filters}
|
||
fields={filterFields}
|
||
onChange={handleFiltersChange}
|
||
size="default"
|
||
i18n={FILTERS_I18N}
|
||
trigger={
|
||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||
<FilterIcon className="size-4" aria-hidden="true" />
|
||
Фильтры
|
||
</Button>
|
||
}
|
||
/>
|
||
{showSearch ? (
|
||
<InputGroup className="max-w-sm min-w-[220px]">
|
||
<InputGroupAddon align="inline-start">
|
||
<SearchIcon className="size-3.5" aria-hidden="true" />
|
||
</InputGroupAddon>
|
||
<InputGroupInput
|
||
value={searchQuery}
|
||
onChange={(e) => handleSearchChange(e.target.value)}
|
||
placeholder={searchPlaceholder}
|
||
aria-label={searchPlaceholder}
|
||
/>
|
||
</InputGroup>
|
||
) : null}
|
||
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
|
||
{toolbarExtra}
|
||
{selectedCount > 0 ? (
|
||
<Badge size="sm" variant="secondary">
|
||
{selectedCount} выбрано
|
||
</Badge>
|
||
) : null}
|
||
{showClear ? (
|
||
<Button type="button" variant="outline" onClick={handleClear}>
|
||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||
Сбросить
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
|
||
<DataGridScrollArea>
|
||
<DataGridTable />
|
||
</DataGridScrollArea>
|
||
|
||
<DataGridPagination
|
||
className="px-5 py-3"
|
||
sizes={[5, 10, 20, 50]}
|
||
rowsPerPageLabel="Строк на странице"
|
||
info="{from}–{to} из {count}"
|
||
previousPageLabel="Назад"
|
||
nextPageLabel="Вперёд"
|
||
/>
|
||
</FramePanel>
|
||
</Frame>
|
||
</DataGrid>
|
||
</div>
|
||
)
|
||
}
|