From 0948455d15c0855123ee3f9f49c4b433af7ce0f7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 14:43:46 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20=D0=BA=20=D0=B2=D0=B8=D0=B4=D1=83=20MikrotikManager=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D1=83=D0=BB=D0=B1=D0=B0=D1=80,=20=D1=81?= =?UTF-8?q?=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D1=8B,=20=D1=88=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=B8=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/portal/data-page-layout.ts | 26 +++ apps/web/src/components/portal/data-page.tsx | 123 +++++++++++ .../src/components/portal/section-grid.tsx | 191 +++++++++--------- .../components/reui-kit/admin-users-grid.tsx | 148 +++++--------- 4 files changed, 290 insertions(+), 198 deletions(-) create mode 100644 apps/web/src/components/portal/data-page-layout.ts create mode 100644 apps/web/src/components/portal/data-page.tsx diff --git a/apps/web/src/components/portal/data-page-layout.ts b/apps/web/src/components/portal/data-page-layout.ts new file mode 100644 index 0000000..b56e5e9 --- /dev/null +++ b/apps/web/src/components/portal/data-page-layout.ts @@ -0,0 +1,26 @@ +import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid' + +export const DATA_GRID_CELL_PAD = 'py-3' +export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3' +export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3' + +export const DATA_GRID_LAYOUT: NonNullable['tableLayout']> = { + rowBorder: true, + headerBackground: true, + headerBorder: true, + columnsResizable: false, +} + +export const DATA_GRID_CLASS_NAMES: NonNullable['tableClassNames']> = { + headerRow: 'border-b border-border', + bodyRow: 'group/row', +} + +export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0' + +/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */ +export function cellPad(index: number, count: number) { + if (index === 0) return DATA_GRID_CELL_PAD_FIRST + if (index === count - 1) return DATA_GRID_CELL_PAD_LAST + return DATA_GRID_CELL_PAD +} diff --git a/apps/web/src/components/portal/data-page.tsx b/apps/web/src/components/portal/data-page.tsx new file mode 100644 index 0000000..6966b49 --- /dev/null +++ b/apps/web/src/components/portal/data-page.tsx @@ -0,0 +1,123 @@ +/** + * Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар + * «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3. + */ +import type { ReactNode } from 'react' +import type { Column } from '@tanstack/react-table' +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react' +import { Frame, FramePanel } from '@/components/reui/frame' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group' +import { cn } from '@authportal/ui/lib/utils' +import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid' + +export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) { + return ( + + {children} + + ) +} + +export function SegmentedControl({ + value, + onChange, + options, + label, +}: { + value: T + onChange: (value: T) => void + options: { value: T; label: string; count?: number }[] + label?: string +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +export function DataPageToolbar({ + leading, + segmented, + filterControl, + search, + onSearchChange, + searchPlaceholder = 'Поиск…', + countLabel, + actions, +}: { + leading?: ReactNode + segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string } + filterControl?: ReactNode + search?: string + onSearchChange?: (value: string) => void + searchPlaceholder?: string + countLabel?: string + actions?: ReactNode +}) { + return ( +
+ {leading} + {segmented ? : null} + {filterControl} + {onSearchChange ? ( + + + + + onSearchChange(e.target.value)} /> + + ) : null} + {countLabel ? {countLabel} : null} + {actions} +
+ ) +} + +export function DataGridSortHeader({ + column, + title, + className, +}: { + column: Column + title: string + className?: string +}) { + const sorted = column.getIsSorted() + if (!column.getCanSort()) { + return {title} + } + return ( + + ) +} diff --git a/apps/web/src/components/portal/section-grid.tsx b/apps/web/src/components/portal/section-grid.tsx index 84bf282..f03057c 100644 --- a/apps/web/src/components/portal/section-grid.tsx +++ b/apps/web/src/components/portal/section-grid.tsx @@ -1,10 +1,9 @@ import { useMemo, useState, type ReactNode } from 'react' -import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-react' +import { Columns3Icon, MoreHorizontalIcon } 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 { @@ -17,7 +16,6 @@ import { 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, @@ -26,12 +24,22 @@ import { 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 { + DATA_GRID_CELL_PAD, + DATA_GRID_CELL_PAD_FIRST, + DATA_GRID_CELL_PAD_LAST, + DATA_GRID_CLASS_NAMES, + DATA_GRID_LAYOUT, +} from './data-page-layout' +import { + DataGridSortHeader, + DataPageCard, + DataPageToolbar, +} from './data-page' import type { Tone } from './tone' import type { RowMark } from './tone' import { TONE_DOT } from './tone' @@ -101,8 +109,7 @@ export function SectionGrid({ rowMenu, onReorder, rowStatus, - pageSize = 8, - footer, + pageSize = 50, emptyMessage = 'Нет записей по текущим фильтрам.', className, }: { @@ -194,20 +201,22 @@ export function SectionGrid({ enableSorting: false, enableResizing: false, size: SELECT_COLUMN_SIZE, + meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' }, }) } - for (const col of visibleColumns) { + visibleColumns.forEach((col, i) => { + const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD list.push({ id: col.id, accessorFn: col.sortValue ?? (() => ''), - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => col.cell(row.original), enableSorting: Boolean(col.sortValue), enableResizing: false, size: col.size, - meta: { headerTitle: col.title }, + meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad }, }) - } + }) list.push({ id: '__actions', header: () => Действия, @@ -242,6 +251,7 @@ export function SectionGrid({ enableSorting: false, enableResizing: false, size: ACTIONS_COLUMN_SIZE, + meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, }) return list }, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick]) @@ -277,6 +287,8 @@ export function SectionGrid({ const paged = data.length > pageSize const showCards = Boolean(card) && cards + const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}` + return ( ({ onRowClick={onRowClick} getRowStatus={rowStatus} emptyMessage={emptyMessage} - tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }} + tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }} + tableClassNames={DATA_GRID_CLASS_NAMES} > - - -
- {title} - {description} -
- {typeof tools === 'function' ? tools(selectedIds) : tools} - {card ? ( - setCards(v === 'cards')} aria-label="Вид списка"> - - - Таблица - - - Карточки - - - - ) : null} - {searchText ? ( - - - - - { - setQuery(e.target.value) - setPagination((p) => ({ ...p, pageIndex: 0 })) - }} - /> - - ) : null} - {columns.length > 1 ? ( - - } - > - - - - {columns.slice(1).map((c) => ( - setHidden((h) => ({ ...h, [c.id]: !v }))} - > - {c.title} - - ))} - - - ) : null} -
- - {views ? ( - { - setView(Number(v)) - setPagination((p) => ({ ...p, pageIndex: 0 })) - }} - > - - {views.map((v, i) => ( - - {v.label} · {rows.filter(v.match).length} + +

{title}

+ { + setView(Number(v)) + setPagination((p) => ({ ...p, pageIndex: 0 })) + }, + options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })), + } + : undefined + } + leading={ + card ? ( + setCards(v === 'cards')} aria-label="Вид списка"> + + + Таблица - ))} - - - ) : null} + + Карточки + +
+
+ ) : undefined + } + search={query} + onSearchChange={ + searchText + ? (v) => { + setQuery(v) + setPagination((p) => ({ ...p, pageIndex: 0 })) + } + : undefined + } + searchPlaceholder={searchPlaceholder} + countLabel={countLabel} + actions={ + <> + {typeof tools === 'function' ? tools(selectedIds) : tools} + {columns.length > 1 ? ( + + } + > + + + + {columns.slice(1).map((c) => ( + setHidden((h) => ({ ...h, [c.id]: !v }))}> + {c.title} + + ))} + + + ) : null} + + } + /> {selectable && selectedIds.length > 0 ? (
({ ) : ( -
- {onReorder ? ( - - ) : ( - - )} +
+ {onReorder ? : }
)} - - - - {footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`} - - {paged && !showCards ? : null} - - - + {paged && !showCards ? : null} + ) } diff --git a/apps/web/src/components/reui-kit/admin-users-grid.tsx b/apps/web/src/components/reui-kit/admin-users-grid.tsx index c127834..babeab0 100644 --- a/apps/web/src/components/reui-kit/admin-users-grid.tsx +++ b/apps/web/src/components/reui-kit/admin-users-grid.tsx @@ -44,6 +44,13 @@ import { DataGridTableRowSelectAll, } from '@/components/reui/data-grid/data-grid-table' import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru' +import { cellPad } from '@/components/portal/data-page-layout' +import { + DATA_GRID_CLASS_NAMES, + DATA_GRID_LAYOUT, +} from '@/components/portal/data-page-layout' +import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page' +import { cn } from '@authportal/ui/lib/utils' import { Filters } from '@/components/reui/filters/filters' import { createFilterQuery, @@ -54,14 +61,6 @@ import type { FilterField, FilterQuery, } from '@/components/reui/filters/filters-types' -import { - Frame, - FrameDescription, - FrameFooter, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' import { AlertDialog, AlertDialogAction, @@ -93,7 +92,6 @@ import { SelectTrigger, SelectValue, } from '@authportal/ui/components/select' -import { Separator } from '@authportal/ui/components/separator' import { Skeleton } from '@authportal/ui/components/skeleton' import { TooltipProvider } from '@authportal/ui/components/tooltip' @@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) { }).format(date) } -function DotSeparator() { - return ( -