Docker / build (push) Failing after 20s
Списки на ResourcePage, KPI на EvoBGP hybrid KpiStatGrid; удалены DataGridCard/SectionCards/ModeToggle; обновлены rules, skill и design contract. Co-authored-by: Cursor <cursoragent@cursor.com>
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { useState, useEffect, type ReactNode } from 'react'
|
||
import {
|
||
useReactTable,
|
||
getCoreRowModel,
|
||
getSortedRowModel,
|
||
getPaginationRowModel,
|
||
flexRender,
|
||
type ColumnDef,
|
||
type SortingState,
|
||
type RowSelectionState,
|
||
type VisibilityState,
|
||
type OnChangeFn,
|
||
} from '@tanstack/react-table'
|
||
import { Columns3Icon } from 'lucide-react'
|
||
|
||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||
import { Button } from '@cfdm/ui/components/button'
|
||
import { cn } from '@cfdm/ui/lib/utils'
|
||
import {
|
||
DataGrid,
|
||
DataGridContainer,
|
||
} from '@/components/reui/data-grid/data-grid'
|
||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
|
||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||
import { EmptyState } from '@/components/empty-state'
|
||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||
import {
|
||
Frame,
|
||
FrameDescription,
|
||
FrameFooter,
|
||
FrameHeader,
|
||
FramePanel,
|
||
FrameTitle,
|
||
} from '@/components/reui/frame'
|
||
|
||
const PAGINATION_LABELS = {
|
||
rowsPerPageLabel: 'Строк на странице',
|
||
info: '{from}–{to} из {count}',
|
||
previousPageLabel: 'Предыдущая страница',
|
||
nextPageLabel: 'Следующая страница',
|
||
pageLabel: 'Страница {page}',
|
||
previousPagesLabel: 'Предыдущие страницы',
|
||
nextPagesLabel: 'Следующие страницы',
|
||
} as const
|
||
|
||
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||
if (headerTitle) return headerTitle
|
||
if (typeof header === 'string') return header
|
||
return ''
|
||
}
|
||
|
||
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
|
||
try {
|
||
const raw = localStorage.getItem(key)
|
||
if (!raw) return undefined
|
||
return JSON.parse(raw) as VisibilityState
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
export { loadStoredColumnVisibility }
|
||
|
||
export interface DataGridColumnVisibilityOption {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
export function dataGridColumnVisibilityOptions<T>(
|
||
cols: DataGridColumn<T>[],
|
||
): DataGridColumnVisibilityOption[] {
|
||
return cols
|
||
.filter((c) => c.enableHiding !== false)
|
||
.map((c) => ({
|
||
id: c.key,
|
||
label: c.headerTitle ?? (typeof c.header === 'string' ? c.header : c.key),
|
||
}))
|
||
}
|
||
|
||
export interface FrameDataGridProps<TData extends object> {
|
||
title?: ReactNode
|
||
description?: ReactNode
|
||
actions?: ReactNode
|
||
columns: ColumnDef<TData, unknown>[]
|
||
data: TData[]
|
||
/** Ключ строки — функция, возвращающая уникальный id. */
|
||
rowId?: (row: TData, index: number) => string
|
||
emptyTitle?: string
|
||
emptyDescription?: string
|
||
emptyAction?: ReactNode
|
||
onRowClick?: (row: TData) => void
|
||
/** Включить пагинацию. По умолчанию true. */
|
||
pagination?: boolean
|
||
/** Размер страницы. По умолчанию 10. */
|
||
pageSize?: number
|
||
/** Footer-контент (итоги). */
|
||
footerContent?: ReactNode
|
||
/** Плотный layout. */
|
||
dense?: boolean
|
||
/** Колонка действий закреплена справа. */
|
||
pinLastColumn?: boolean
|
||
/** Сортировка по умолчанию. */
|
||
initialSorting?: SortingState
|
||
/** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */
|
||
virtualization?: boolean
|
||
/** Высота viewport для виртуализации (px). По умолчанию 480. */
|
||
height?: number
|
||
/** Включить выбор строк (чекбоксы). */
|
||
enableRowSelection?: boolean
|
||
/** Callback при изменении выбора. */
|
||
onRowSelectionChange?: (selectedIds: string[]) => void
|
||
/** Показать picker видимости колонок. */
|
||
enableColumnVisibility?: boolean
|
||
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
|
||
columnVisibility?: VisibilityState
|
||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
|
||
columnVisibilityTrigger?: boolean
|
||
/** Ключ localStorage для сохранения видимости колонок. */
|
||
columnVisibilityStorageKey?: string
|
||
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||
initialColumnVisibility?: VisibilityState
|
||
className?: string
|
||
}
|
||
|
||
function DataGridSectionHeader({
|
||
title,
|
||
description,
|
||
actions,
|
||
}: {
|
||
title?: ReactNode
|
||
description?: ReactNode
|
||
actions?: ReactNode
|
||
}) {
|
||
if (!title && !description && !actions) return null
|
||
|
||
return (
|
||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||
<div className="flex min-w-0 flex-col gap-px">
|
||
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
||
{description ? (
|
||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||
) : null}
|
||
</div>
|
||
{actions ? (
|
||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||
) : null}
|
||
</FrameHeader>
|
||
)
|
||
}
|
||
|
||
function DataGridPaginationBar() {
|
||
return (
|
||
<FrameFooter>
|
||
<DataGridPagination {...PAGINATION_LABELS} />
|
||
</FrameFooter>
|
||
)
|
||
}
|
||
|
||
function FrameDataGridBody<TData extends object>({
|
||
table,
|
||
data,
|
||
emptyTitle,
|
||
onRowClick,
|
||
dense,
|
||
virtualization,
|
||
height,
|
||
footerContent,
|
||
showPagination,
|
||
enableColumnVisibility,
|
||
}: {
|
||
table: ReturnType<typeof useReactTable<TData>>
|
||
data: TData[]
|
||
emptyTitle: string
|
||
onRowClick?: (row: TData) => void
|
||
dense: boolean
|
||
virtualization: boolean
|
||
height: number
|
||
footerContent?: ReactNode
|
||
showPagination: boolean
|
||
enableColumnVisibility: boolean
|
||
}) {
|
||
return (
|
||
<DataGrid
|
||
table={table}
|
||
recordCount={data.length}
|
||
onRowClick={onRowClick}
|
||
emptyMessage={emptyTitle}
|
||
tableLayout={{
|
||
dense,
|
||
stripped: true,
|
||
rowBorder: true,
|
||
headerSticky: true,
|
||
headerBackground: true,
|
||
headerBorder: true,
|
||
width: 'auto',
|
||
columnsVisibility: enableColumnVisibility,
|
||
columnsResizable: false,
|
||
columnsPinnable: false,
|
||
columnsMovable: false,
|
||
rowsDraggable: false,
|
||
rowsPinnable: false,
|
||
}}
|
||
tableClassNames={{
|
||
header: 'text-xs font-medium text-muted-foreground',
|
||
}}
|
||
>
|
||
<DataGridContainer border={false}>
|
||
{virtualization ? (
|
||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||
</DataGridScrollArea>
|
||
) : (
|
||
<DataGridTable footerContent={footerContent} />
|
||
)}
|
||
</DataGridContainer>
|
||
{showPagination ? <DataGridPaginationBar /> : null}
|
||
</DataGrid>
|
||
)
|
||
}
|
||
|
||
export function FrameDataGrid<TData extends object>({
|
||
title,
|
||
description,
|
||
actions,
|
||
columns,
|
||
data,
|
||
rowId,
|
||
emptyTitle = 'Нет записей',
|
||
emptyDescription,
|
||
emptyAction,
|
||
onRowClick,
|
||
pagination,
|
||
pageSize = 10,
|
||
footerContent,
|
||
dense = true,
|
||
pinLastColumn = false,
|
||
initialSorting,
|
||
virtualization = false,
|
||
height = 480,
|
||
enableRowSelection = false,
|
||
onRowSelectionChange,
|
||
enableColumnVisibility = false,
|
||
columnVisibility: columnVisibilityProp,
|
||
onColumnVisibilityChange,
|
||
columnVisibilityTrigger,
|
||
columnVisibilityStorageKey,
|
||
initialColumnVisibility,
|
||
className,
|
||
}: FrameDataGridProps<TData>) {
|
||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
|
||
const stored = columnVisibilityStorageKey
|
||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||
: undefined
|
||
return { ...initialColumnVisibility, ...stored }
|
||
})
|
||
|
||
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
|
||
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
|
||
? (onColumnVisibilityChange ?? (() => undefined))
|
||
: setInternalColumnVisibility
|
||
|
||
useEffect(() => {
|
||
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||
|
||
const selectColumn: ColumnDef<TData, unknown> = {
|
||
id: 'select',
|
||
header: ({ table }) => (
|
||
<Checkbox
|
||
checked={table.getIsAllPageRowsSelected()}
|
||
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||
aria-label="Выбрать все"
|
||
/>
|
||
),
|
||
cell: ({ row }) => (
|
||
<Checkbox
|
||
checked={row.getIsSelected()}
|
||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||
aria-label="Выбрать строку"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
),
|
||
enableSorting: false,
|
||
enableHiding: false,
|
||
meta: { cellClassName: 'w-10' },
|
||
}
|
||
|
||
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
|
||
|
||
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
|
||
|
||
const showPagination = pagination ?? true
|
||
|
||
const table = useReactTable<TData>({
|
||
data,
|
||
columns: tableColumns,
|
||
state: {
|
||
sorting,
|
||
columnVisibility,
|
||
...(enableRowSelection ? { rowSelection } : {}),
|
||
},
|
||
onSortingChange: setSorting,
|
||
onColumnVisibilityChange: setColumnVisibility,
|
||
onRowSelectionChange: enableRowSelection
|
||
? (updater) => {
|
||
setRowSelection((prev) => {
|
||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||
if (onRowSelectionChange && rowId) {
|
||
const ids = Object.keys(next).filter((k) => next[k])
|
||
onRowSelectionChange(ids)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
: undefined,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getSortedRowModel: getSortedRowModel(),
|
||
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
||
initialState: {
|
||
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
|
||
...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}),
|
||
},
|
||
getRowId: rowId
|
||
? (row, index) => rowId(row, index)
|
||
: undefined,
|
||
enableColumnPinning: pinLastColumn,
|
||
enableRowSelection,
|
||
enableHiding: enableColumnVisibility,
|
||
})
|
||
|
||
const showColumnVisibilityTrigger =
|
||
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
||
|
||
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
||
<DataGridColumnVisibility
|
||
table={table}
|
||
trigger={
|
||
<Button variant="ghost" size="sm">
|
||
<Columns3Icon data-icon="inline-start" />
|
||
Колонки
|
||
</Button>
|
||
}
|
||
/>
|
||
) : null
|
||
|
||
const headerActions = actions ? (
|
||
<div className="flex items-center gap-2">
|
||
{columnVisibilityAction}
|
||
{actions}
|
||
</div>
|
||
) : (
|
||
columnVisibilityAction
|
||
)
|
||
|
||
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||
|
||
if (data.length === 0) {
|
||
return (
|
||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||
{hasHeader ? (
|
||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||
) : null}
|
||
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
|
||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|
||
|
||
const gridBody = (
|
||
<FrameDataGridBody
|
||
table={table}
|
||
data={data}
|
||
emptyTitle={emptyTitle}
|
||
onRowClick={onRowClick}
|
||
dense={dense}
|
||
virtualization={virtualization}
|
||
height={height}
|
||
footerContent={footerContent}
|
||
showPagination={showPagination}
|
||
enableColumnVisibility={enableColumnVisibility}
|
||
/>
|
||
)
|
||
|
||
return (
|
||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||
{hasHeader ? (
|
||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||
) : null}
|
||
<FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|
||
|
||
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||
export function columnDefFromDataGrid<T>(
|
||
cols: DataGridColumn<T>[],
|
||
): ColumnDef<T, unknown>[] {
|
||
return cols.map((c) => {
|
||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||
const Icon = c.icon
|
||
const sortable = c.sortable ?? Boolean(Icon)
|
||
|
||
return {
|
||
id: c.key,
|
||
...(sortable
|
||
? {
|
||
accessorFn: c.sortValue
|
||
? (row: T) => c.sortValue!(row)
|
||
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
|
||
}
|
||
: {}),
|
||
header: Icon
|
||
? ({ column }) => (
|
||
<DataGridColumnHeader
|
||
column={column}
|
||
title={title}
|
||
icon={<Icon />}
|
||
/>
|
||
)
|
||
: () => c.header,
|
||
cell: ({ row }) => c.cell(row.original, row.index),
|
||
enableSorting: sortable,
|
||
enableHiding: c.enableHiding ?? true,
|
||
meta: {
|
||
headerTitle: title || undefined,
|
||
cellClassName: c.className,
|
||
headerClassName: c.headerClassName,
|
||
},
|
||
}
|
||
})
|
||
}
|
||
|
||
/** @deprecated Используйте columnDefFromDataGrid */
|
||
export const columnDefFromDataTable = columnDefFromDataGrid
|
||
|
||
/** re-export flexRender для удобства использования в колонках. */
|
||
export { flexRender }
|