Files
vps-tracker/apps/web/src/components/reui-kit/frame-data-grid.tsx
T
DenozordecandCursor 9faec59338
Docker / build (push) Failing after 20s
feat(web): унифицировать ReUI PRO — ResourcePage, KpiStatGrid, chrome
Списки на ResourcePage, KPI на EvoBGP hybrid KpiStatGrid; удалены DataGridCard/SectionCards/ModeToggle; обновлены rules, skill и design contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:22:49 +07:00

449 lines
14 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 { 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 }