Files
EvoFirewall/apps/web/src/components/reui-kit/resource-page.tsx
T
DenozordecandClaude Sonnet 5.5 10b5bbc9cf
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
feat(web): оболочка, KPI-полоса и таблицы по объединённому макету
Боковое меню, шапка, монитор системы и палитра Ctrl+K переведены на общие блоки макета, данные из живых запросов (агенты, списки, наборы правил). KPI стали полосой в одном Frame, таблицы ResourcePage получили тулбар как в MikrotikManager (сегменты, фильтры, поиск, счётчик), подложку шапки и ячейки py-3. Старая оболочка удалена.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 19:28:46 +07:00

537 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}