- поиск и фильтры страниц перенесены в шапку рамки рядом с заголовком, ListFiltersBar стал однострочным тулбаром - шапка таблицы с подложкой, строки без зебры (общий kitDataGridTableLayout) - у таблиц заголовки и подписи из досок макета - IP в списке VPS моноширинным шрифтом и без обрезки, компактная иконка в ячейке-названии Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
522 lines
15 KiB
TypeScript
522 lines
15 KiB
TypeScript
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} />
|
||
}
|