498 lines
20 KiB
TypeScript
498 lines
20 KiB
TypeScript
import { useMemo, useState, type ReactNode } from 'react'
|
|
import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
|
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
|
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
import {
|
|
DataGridTableDndRowHandle,
|
|
DataGridTableDndRows,
|
|
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
|
|
import type { DragEndEvent } from '@dnd-kit/core'
|
|
import {
|
|
DataGridTable,
|
|
DataGridTableRowSelect,
|
|
DataGridTableRowSelectAll,
|
|
} from '@/components/reui/data-grid/data-grid-table'
|
|
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
|
import { Button } from '@authportal/ui/components/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@authportal/ui/components/dropdown-menu'
|
|
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
|
import { Separator } from '@authportal/ui/components/separator'
|
|
import { Switch } from '@authportal/ui/components/switch'
|
|
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
|
import { cn } from '@authportal/ui/lib/utils'
|
|
import { StatusBadge } from './bits'
|
|
import type { Tone } from './mock'
|
|
import type { RowMark } from './preview-store'
|
|
import { TONE_DOT } from './tone'
|
|
|
|
export type GridColumn<T> = {
|
|
id: string
|
|
title: string
|
|
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
|
|
size: number
|
|
cell: (row: T) => ReactNode
|
|
/** Ключ сортировки; без него колонка не сортируется. */
|
|
sortValue?: (row: T) => string | number
|
|
}
|
|
|
|
export type GridView<T> = { label: string; match: (row: T) => boolean }
|
|
|
|
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
|
|
export type GridCard = {
|
|
name: string
|
|
sub?: string
|
|
tone: Tone
|
|
status: string
|
|
cells: Array<{ l: string; v: string }>
|
|
}
|
|
|
|
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
|
|
export type RowMenuConfig = {
|
|
onOpen?: () => void
|
|
toggleLabel?: string
|
|
onToggle?: () => void
|
|
onDuplicate?: () => void
|
|
onDelete?: () => void
|
|
}
|
|
|
|
export type GridBulk = {
|
|
onLabel?: string
|
|
offLabel?: string
|
|
onRun?: (ids: string[]) => void
|
|
offRun?: (ids: string[]) => void
|
|
onDelete?: (ids: string[]) => void
|
|
}
|
|
|
|
const SELECT_COLUMN_SIZE = 44
|
|
const DRAG_COLUMN_SIZE = 44
|
|
const ACTIONS_COLUMN_SIZE = 120
|
|
|
|
/**
|
|
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
|
|
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
|
|
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
|
|
*/
|
|
export function PreviewSectionGrid<T extends object>({
|
|
title,
|
|
description,
|
|
rows,
|
|
columns,
|
|
getRowId,
|
|
views,
|
|
searchText,
|
|
searchPlaceholder = 'Поиск…',
|
|
onRowClick,
|
|
selectable = true,
|
|
bulk,
|
|
tools,
|
|
card,
|
|
rowSwitch,
|
|
rowMenu,
|
|
onReorder,
|
|
rowStatus,
|
|
pageSize = 8,
|
|
footer,
|
|
emptyMessage = 'Нет записей по текущим фильтрам.',
|
|
className,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
rows: T[]
|
|
columns: GridColumn<T>[]
|
|
getRowId: (row: T) => string
|
|
views?: GridView<T>[]
|
|
searchText?: (row: T) => string
|
|
searchPlaceholder?: string
|
|
onRowClick?: (row: T) => void
|
|
selectable?: boolean
|
|
bulk?: GridBulk
|
|
/** Кнопки действий в шапке; функция получает id выбранных строк. */
|
|
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
|
|
/** Включает переключатель «Таблица / Карточки». */
|
|
card?: (row: T) => GridCard
|
|
/** Переключатель «включено» в последней колонке (если у строки он есть). */
|
|
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
|
|
rowMenu?: (row: T) => RowMenuConfig
|
|
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
|
|
onReorder?: (activeId: string, overId: string) => void
|
|
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
|
|
rowStatus?: (row: T) => RowMark | undefined
|
|
pageSize?: number
|
|
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
|
|
footer?: string
|
|
emptyMessage?: string
|
|
className?: string
|
|
}) {
|
|
const storageKey = `design-preview:view:${title}`
|
|
const [view, setViewState] = useState(() => {
|
|
try {
|
|
const v = Number(window.localStorage.getItem(storageKey))
|
|
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
|
|
} catch {
|
|
return 0
|
|
}
|
|
})
|
|
const setView = (i: number) => {
|
|
setViewState(i)
|
|
try {
|
|
window.localStorage.setItem(storageKey, String(i))
|
|
} catch {
|
|
/* без сохранения вида */
|
|
}
|
|
}
|
|
const [query, setQuery] = useState('')
|
|
const [sorting, setSorting] = useState<SortingState>([])
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
|
|
const [hidden, setHidden] = useState<Record<string, boolean>>({})
|
|
const [cards, setCards] = useState(false)
|
|
|
|
const data = useMemo(() => {
|
|
const matchView = views?.[view]?.match
|
|
const q = query.trim().toLowerCase()
|
|
return rows.filter((row) => {
|
|
if (matchView && !matchView(row)) return false
|
|
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
|
|
return true
|
|
})
|
|
}, [rows, views, view, query, searchText])
|
|
|
|
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
|
|
|
|
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
|
|
const list: ColumnDef<DataGridFeatures, T>[] = []
|
|
if (onReorder) {
|
|
list.push({
|
|
id: 'drag',
|
|
header: () => <span className="sr-only">Порядок</span>,
|
|
cell: () => <DataGridTableDndRowHandle />,
|
|
enableSorting: false,
|
|
enableResizing: false,
|
|
size: DRAG_COLUMN_SIZE,
|
|
})
|
|
}
|
|
if (selectable) {
|
|
list.push({
|
|
id: 'select',
|
|
header: () => <DataGridTableRowSelectAll />,
|
|
cell: ({ row }) => (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<DataGridTableRowSelect row={row} />
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
enableResizing: false,
|
|
size: SELECT_COLUMN_SIZE,
|
|
})
|
|
}
|
|
for (const col of visibleColumns) {
|
|
list.push({
|
|
id: col.id,
|
|
accessorFn: col.sortValue ?? (() => ''),
|
|
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
|
|
cell: ({ row }) => col.cell(row.original),
|
|
enableSorting: Boolean(col.sortValue),
|
|
enableResizing: false,
|
|
size: col.size,
|
|
meta: { headerTitle: col.title },
|
|
})
|
|
}
|
|
list.push({
|
|
id: '__actions',
|
|
header: () => <span className="sr-only">Действия</span>,
|
|
cell: ({ row }) => {
|
|
const r = row.original
|
|
const on = rowSwitch?.value(r)
|
|
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
|
|
const soon = (what: string) => () => toast(`${what} (макет)`)
|
|
return (
|
|
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
|
{on !== undefined && rowSwitch ? (
|
|
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
|
|
) : null}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
|
|
<MoreHorizontalIcon aria-hidden />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-48">
|
|
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
|
|
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
|
|
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
|
|
Удалить…
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)
|
|
},
|
|
enableSorting: false,
|
|
enableResizing: false,
|
|
size: ACTIONS_COLUMN_SIZE,
|
|
})
|
|
return list
|
|
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
|
|
|
const table = useTable({
|
|
features: dataGridFeatures,
|
|
columns: defs,
|
|
data,
|
|
getRowId,
|
|
state: { sorting, rowSelection, pagination },
|
|
enableRowSelection: selectable,
|
|
onSortingChange: setSorting,
|
|
onRowSelectionChange: setRowSelection,
|
|
onPaginationChange: setPagination,
|
|
})
|
|
|
|
const dataIds = table.getRowModel().rows.map((r) => r.id)
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
if (!onReorder || !active || !over || active.id === over.id) return
|
|
onReorder(String(active.id), String(over.id))
|
|
}
|
|
|
|
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
|
|
const minWidth = Math.round(
|
|
visibleColumns.reduce(
|
|
(sum, c) => sum + c.size,
|
|
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
|
|
) * 0.7,
|
|
)
|
|
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
|
|
const clear = () => setRowSelection({})
|
|
const paged = data.length > pageSize
|
|
const showCards = Boolean(card) && cards
|
|
|
|
return (
|
|
<DataGrid
|
|
table={table}
|
|
i18n={DATA_GRID_RU}
|
|
recordCount={data.length}
|
|
onRowClick={onRowClick}
|
|
getRowStatus={rowStatus}
|
|
emptyMessage={emptyMessage}
|
|
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
|
|
>
|
|
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
|
|
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
|
|
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
|
|
<FrameTitle>{title}</FrameTitle>
|
|
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
|
</div>
|
|
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
|
{card ? (
|
|
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
|
<TabsList className="h-9! gap-0.5 p-[3px]">
|
|
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
|
Таблица
|
|
</TabsTrigger>
|
|
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
|
Карточки
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
) : null}
|
|
{searchText ? (
|
|
<InputGroup className="w-[220px] max-w-full">
|
|
<InputGroupAddon>
|
|
<SearchIcon aria-hidden />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
value={query}
|
|
placeholder="Поиск"
|
|
aria-label={searchPlaceholder}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
|
}}
|
|
/>
|
|
</InputGroup>
|
|
) : null}
|
|
{columns.length > 1 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
|
>
|
|
<Columns3Icon aria-hidden />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-52">
|
|
{columns.slice(1).map((c) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={c.id}
|
|
checked={!hidden[c.id]}
|
|
onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}
|
|
>
|
|
{c.title}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</FrameHeader>
|
|
<FramePanel className="min-w-0 p-0 shadow-none">
|
|
{views ? (
|
|
<Tabs
|
|
value={String(view)}
|
|
onValueChange={(v) => {
|
|
setView(Number(v))
|
|
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
|
}}
|
|
>
|
|
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto px-4">
|
|
{views.map((v, i) => (
|
|
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
|
|
{v.label} · {rows.filter(v.match).length}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
) : null}
|
|
{selectable && selectedIds.length > 0 ? (
|
|
<div
|
|
role="toolbar"
|
|
aria-label="Массовые действия"
|
|
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
|
|
>
|
|
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (bulk?.onRun) bulk.onRun(selectedIds)
|
|
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
|
|
clear()
|
|
}}
|
|
>
|
|
{bulk?.onLabel ?? 'Включить'}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (bulk?.offRun) bulk.offRun(selectedIds)
|
|
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
|
|
clear()
|
|
}}
|
|
>
|
|
{bulk?.offLabel ?? 'Выключить'}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="text-destructive"
|
|
onClick={() => {
|
|
if (bulk?.onDelete) bulk.onDelete(selectedIds)
|
|
else toast(`Удаление: ${selectedIds.length} (макет)`)
|
|
}}
|
|
>
|
|
Удалить…
|
|
</Button>
|
|
<span className="flex-1" />
|
|
<Button type="button" variant="outline" onClick={clear}>
|
|
Снять выбор
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
{showCards && card ? (
|
|
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
|
|
{data.map((r) => {
|
|
const c = card(r)
|
|
const id = getRowId(r)
|
|
const marked = rowStatus?.(r)
|
|
return (
|
|
<li
|
|
key={id}
|
|
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Выбрать ${c.name}`}
|
|
checked={Boolean(rowSelection[id])}
|
|
onChange={(e) =>
|
|
setRowSelection((s) => {
|
|
const next = { ...s }
|
|
if (e.target.checked) next[id] = true
|
|
else delete next[id]
|
|
return next
|
|
})
|
|
}
|
|
className="accent-primary size-4"
|
|
/>
|
|
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onRowClick?.(r)}
|
|
className="flex min-w-0 items-start gap-2 text-left"
|
|
aria-label={`Открыть: ${c.name}`}
|
|
>
|
|
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
|
|
<span className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium">{c.name}</span>
|
|
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
|
|
</span>
|
|
</button>
|
|
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
|
{c.cells.map((x) => (
|
|
<div key={x.l} className="min-w-0">
|
|
<dt className="text-muted-foreground">{x.l}</dt>
|
|
<dd className="truncate font-mono">{x.v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<DataGridScrollArea>
|
|
<div className="pb-3" style={{ minWidth }}>
|
|
{onReorder ? (
|
|
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
|
|
) : (
|
|
<DataGridTable />
|
|
)}
|
|
</div>
|
|
</DataGridScrollArea>
|
|
)}
|
|
<Separator />
|
|
<FrameFooter className="flex-wrap justify-between gap-2">
|
|
<span className="text-muted-foreground text-xs">
|
|
{footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
|
|
</span>
|
|
{paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
|
|
</FrameFooter>
|
|
</FramePanel>
|
|
</Frame>
|
|
</DataGrid>
|
|
)
|
|
}
|
|
|
|
/** Двухстрочная ячейка: заголовок + подпись. */
|
|
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
|
|
return (
|
|
<div className="flex min-w-0 items-start gap-2">
|
|
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium">{title}</span>
|
|
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|