Files
vps-tracker/apps/web/src/components/reui-kit/resource-page.tsx
T
DenozordecandClaude Opus 5.5 a4a8b9562d feat(web): унифицировать таблицы по раскладке MikrotikManager v2
- поиск и фильтры страниц перенесены в шапку рамки рядом с заголовком,
  ListFiltersBar стал однострочным тулбаром
- шапка таблицы с подложкой, строки без зебры (общий kitDataGridTableLayout)
- у таблиц заголовки и подписи из досок макета
- IP в списке VPS моноширинным шрифтом и без обрезки, компактная
  иконка в ячейке-названии

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:06:21 +07:00

522 lines
15 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 ReactNode } from 'react'
import {
useTable,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid, DataGridContainer, dataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
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 {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
import {
FrameDataGrid,
kitDataGridTableLayout,
type DataGridColumnDef,
type FrameDataGridProps,
} from './frame-data-grid'
export interface ResourcePageTab {
id: string
label: string
count?: number
}
type SimpleGridPassthrough<T extends object> = Pick<
FrameDataGridProps<T>,
| 'onRowClick'
| 'pagination'
| 'footerContent'
| 'dense'
| 'initialSorting'
| 'virtualization'
| 'height'
| 'onRowSelectionChange'
| 'enableColumnVisibility'
| 'columnVisibility'
| 'onColumnVisibilityChange'
| 'columnVisibilityTrigger'
| 'columnVisibilityStorageKey'
| 'initialColumnVisibility'
| 'className'
| 'tableWidth'
| 'horizontalScroll'
| 'pinLeftColumnIds'
| 'columnPinControls'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
/** Optional when hideHeader / embedded under PageHeader. */
title?: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
/** Empty / omit → simple Frame grid (no ReUI Filters). Preview: data-grid-filtering-2 */
filterFields?: FilterFieldConfig[]
filters?: Filter[]
onFiltersChange?: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue?: (item: T, field: string) => unknown
columns: DataGridColumnDef<T>[]
data: T[]
getRowId: (row: T, index?: number) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
pinLastColumn?: boolean
/** FrameDataGrid aliases when used as simple list. */
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
/** Alias for primaryAction (FrameDataGrid `actions`). */
actions?: ReactNode
}
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>
)
}
function ResourceLoadError({
error,
onRetry,
}: {
error?: Error | null
onRetry?: () => void
}) {
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>
)
}
const noopFiltersChange = (_filters: Filter[]) => {}
const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
function ResourcePageSimple<T extends object>({
title = '',
description,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
actions,
emptyState,
emptyTitle,
emptyDescription,
emptyAction,
pageSize = 10,
enableRowSelection = false,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
onRowClick,
pagination,
footerContent,
dense,
initialSorting,
virtualization,
height,
onRowSelectionChange,
enableColumnVisibility,
columnVisibility,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
tableWidth,
horizontalScroll,
pinLeftColumnIds,
columnPinControls,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
const headerActions = primaryAction ?? actions
const resolvedEmpty = emptyState ?? (
emptyTitle || emptyDescription || emptyAction
? { title: emptyTitle ?? 'Нет записей', description: emptyDescription, action: emptyAction }
: undefined
)
return (
<div className="w-full">
<FrameDataGrid
title={hideHeader ? undefined : title || undefined}
description={hideHeader ? undefined : description}
actions={hideHeader ? undefined : headerActions}
toolbar={toolbarExtra}
columns={columns}
data={data}
rowId={getRowId}
emptyTitle={resolvedEmpty?.title}
emptyDescription={resolvedEmpty?.description}
emptyAction={resolvedEmpty?.action}
pinLastColumn={pinLastColumn}
pageSize={pageSize}
enableRowSelection={enableRowSelection}
onRowClick={onRowClick}
pagination={pagination}
footerContent={footerContent}
dense={dense}
initialSorting={initialSorting}
virtualization={virtualization}
height={height}
onRowSelectionChange={onRowSelectionChange}
enableColumnVisibility={enableColumnVisibility}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
columnVisibilityTrigger={columnVisibilityTrigger}
columnVisibilityStorageKey={columnVisibilityStorageKey}
initialColumnVisibility={initialColumnVisibility}
className={className}
tableWidth={tableWidth}
horizontalScroll={horizontalScroll}
pinLeftColumnIds={pinLeftColumnIds}
columnPinControls={columnPinControls}
/>
</div>
)
}
function ResourcePageFiltered<T extends object>({
title = '',
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields = [],
filters = [],
onFiltersChange = noopFiltersChange,
onClearFilters,
getFilterFieldValue = defaultGetFilterFieldValue as (item: T, field: string) => unknown,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
actions,
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
enableColumnVisibility = false,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0
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 = showFilters
? applyFiltersToData(data, filters, getFilterFieldValue)
: data
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = showFilters
? applyFiltersToData(data, filters, getFilterFieldValue)
: data
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, showFilters])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedCount = selectedIds.length
const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : ''
const enablePinning = Boolean(pinLastColumn && lastColId)
const columnPinning = {
start: [] as string[],
end: enablePinning ? [lastColId] : [],
}
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
const table = useTable({
features: dataGridFeatures,
data: filteredData,
columns,
getRowId: (row) => getRowId(row),
state: {
sorting,
rowSelection,
pagination,
...(enablePinning ? { columnPinning } : {}),
},
initialState: enablePinning ? { columnPinning } : undefined,
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
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])
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 <ResourceLoadError error={error} onRetry={onRetry} />
if (data.length === 0 && emptyState) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
</FramePanel>
</Frame>
)
}
return (
<div className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={kitDataGridTableLayout({
dense: true,
width: 'fixed',
columnsPinnable: enablePinning,
columnsVisibility: enableColumnVisibility,
})}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{/* Раскладка MikrotikManager v2: заголовок, затем фильтры/поиск/сброс и действия в одной шапке. */}
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
{!hideHeader ? (
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
) : null}
</div>
) : (
<div className="flex-1" />
)}
{showFilters ? (
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{filters.length > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{filters.length}
</Badge>
) : null}
</Button>
}
/>
) : null}
{toolbarExtra}
{showFilters && onClearFilters && filters.length > 0 ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
{headerActions && !hideHeader ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{headerActions}</div>
) : null}
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
) : null}
<DataGridContainer border={false}>
<DataGridTable />
</DataGridContainer>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from}–{to} из {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
)
}
/**
* Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
* Without filterFields/tabs → simple CRUD grid (FrameDataGrid under the hood).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export function ResourcePage<T extends object>(props: ResourcePageProps<T>) {
const showFilters = (props.filterFields?.length ?? 0) > 0
const hasTabs = (props.tabs?.length ?? 0) > 0
if (!showFilters && !hasTabs) {
return <ResourcePageSimple {...props} />
}
return <ResourcePageFiltered {...props} />
}