fix(web): привести таблицы к виду MikrotikManager — тулбар, сегменты, шапка и ячейки
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m12s
CD / publish (push) Successful in 1m31s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m12s
CD / publish (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
e7b34ce6ae
commit
0948455d15
4 files changed
+290
-198
No files matched your search
@@ -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<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
|
||||||
|
rowBorder: true,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
columnsResizable: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['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
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||||
|
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
options: { value: T; label: string; count?: number }[]
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||||
|
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||||
|
{leading}
|
||||||
|
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||||
|
{filterControl}
|
||||||
|
{onSearchChange ? (
|
||||||
|
<InputGroup className="max-w-sm min-w-[220px]">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-3.5" aria-hidden />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||||
|
</InputGroup>
|
||||||
|
) : null}
|
||||||
|
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGridSortHeader<TData extends object>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
column: Column<DataGridFeatures, TData, unknown>
|
||||||
|
title: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const sorted = column.getIsSorted()
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{sorted === 'asc' ? (
|
||||||
|
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo, useState, type ReactNode } from 'react'
|
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 { toast } from 'sonner'
|
||||||
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
||||||
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
||||||
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
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 { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
DataGridTableRowSelect,
|
DataGridTableRowSelect,
|
||||||
DataGridTableRowSelectAll,
|
DataGridTableRowSelectAll,
|
||||||
} from '@/components/reui/data-grid/data-grid-table'
|
} 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 { Button } from '@authportal/ui/components/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -26,12 +24,22 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@authportal/ui/components/dropdown-menu'
|
} 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 { Switch } from '@authportal/ui/components/switch'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
import { cn } from '@authportal/ui/lib/utils'
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
import { StatusBadge } from './bits'
|
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 { Tone } from './tone'
|
||||||
import type { RowMark } from './tone'
|
import type { RowMark } from './tone'
|
||||||
import { TONE_DOT } from './tone'
|
import { TONE_DOT } from './tone'
|
||||||
@@ -101,8 +109,7 @@ export function SectionGrid<T extends object>({
|
|||||||
rowMenu,
|
rowMenu,
|
||||||
onReorder,
|
onReorder,
|
||||||
rowStatus,
|
rowStatus,
|
||||||
pageSize = 8,
|
pageSize = 50,
|
||||||
footer,
|
|
||||||
emptyMessage = 'Нет записей по текущим фильтрам.',
|
emptyMessage = 'Нет записей по текущим фильтрам.',
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
@@ -194,20 +201,22 @@ export function SectionGrid<T extends object>({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableResizing: false,
|
enableResizing: false,
|
||||||
size: SELECT_COLUMN_SIZE,
|
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({
|
list.push({
|
||||||
id: col.id,
|
id: col.id,
|
||||||
accessorFn: col.sortValue ?? (() => ''),
|
accessorFn: col.sortValue ?? (() => ''),
|
||||||
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
|
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
|
||||||
cell: ({ row }) => col.cell(row.original),
|
cell: ({ row }) => col.cell(row.original),
|
||||||
enableSorting: Boolean(col.sortValue),
|
enableSorting: Boolean(col.sortValue),
|
||||||
enableResizing: false,
|
enableResizing: false,
|
||||||
size: col.size,
|
size: col.size,
|
||||||
meta: { headerTitle: col.title },
|
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
list.push({
|
list.push({
|
||||||
id: '__actions',
|
id: '__actions',
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
@@ -242,6 +251,7 @@ export function SectionGrid<T extends object>({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableResizing: false,
|
enableResizing: false,
|
||||||
size: ACTIONS_COLUMN_SIZE,
|
size: ACTIONS_COLUMN_SIZE,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
||||||
@@ -277,6 +287,8 @@ export function SectionGrid<T extends object>({
|
|||||||
const paged = data.length > pageSize
|
const paged = data.length > pageSize
|
||||||
const showCards = Boolean(card) && cards
|
const showCards = Boolean(card) && cards
|
||||||
|
|
||||||
|
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
table={table}
|
table={table}
|
||||||
@@ -285,82 +297,72 @@ export function SectionGrid<T extends object>({
|
|||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
getRowStatus={rowStatus}
|
getRowStatus={rowStatus}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
|
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
|
||||||
|
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||||
>
|
>
|
||||||
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
|
<DataPageCard className={className}>
|
||||||
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
|
<h2 className="sr-only">{title}</h2>
|
||||||
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
|
<DataPageToolbar
|
||||||
<FrameTitle>{title}</FrameTitle>
|
segmented={
|
||||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
views
|
||||||
</div>
|
? {
|
||||||
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
value: String(view),
|
||||||
{card ? (
|
label: description,
|
||||||
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
onChange: (v) => {
|
||||||
<TabsList className="h-9! gap-0.5 p-[3px]">
|
setView(Number(v))
|
||||||
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||||
Таблица
|
},
|
||||||
</TabsTrigger>
|
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
|
||||||
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
}
|
||||||
Карточки
|
: undefined
|
||||||
</TabsTrigger>
|
}
|
||||||
</TabsList>
|
leading={
|
||||||
</Tabs>
|
card ? (
|
||||||
) : null}
|
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
||||||
{searchText ? (
|
<TabsList className="h-9! gap-0.5 p-[3px]">
|
||||||
<InputGroup className="w-[220px] max-w-full">
|
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
||||||
<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>
|
</TabsTrigger>
|
||||||
))}
|
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
||||||
</TabsList>
|
Карточки
|
||||||
</Tabs>
|
</TabsTrigger>
|
||||||
) : null}
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
) : 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 ? (
|
||||||
|
<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}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{selectable && selectedIds.length > 0 ? (
|
{selectable && selectedIds.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
@@ -461,24 +463,13 @@ export function SectionGrid<T extends object>({
|
|||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<DataGridScrollArea>
|
<DataGridScrollArea>
|
||||||
<div className="pb-3" style={{ minWidth }}>
|
<div style={{ minWidth }}>
|
||||||
{onReorder ? (
|
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||||
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
|
|
||||||
) : (
|
|
||||||
<DataGridTable />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DataGridScrollArea>
|
</DataGridScrollArea>
|
||||||
)}
|
)}
|
||||||
<Separator />
|
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
|
||||||
<FrameFooter className="flex-wrap justify-between gap-2">
|
</DataPageCard>
|
||||||
<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>
|
</DataGrid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ import {
|
|||||||
DataGridTableRowSelectAll,
|
DataGridTableRowSelectAll,
|
||||||
} from '@/components/reui/data-grid/data-grid-table'
|
} from '@/components/reui/data-grid/data-grid-table'
|
||||||
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
|
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 { Filters } from '@/components/reui/filters/filters'
|
||||||
import {
|
import {
|
||||||
createFilterQuery,
|
createFilterQuery,
|
||||||
@@ -54,14 +61,6 @@ import type {
|
|||||||
FilterField,
|
FilterField,
|
||||||
FilterQuery,
|
FilterQuery,
|
||||||
} from '@/components/reui/filters/filters-types'
|
} from '@/components/reui/filters/filters-types'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -93,7 +92,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@authportal/ui/components/select'
|
} from '@authportal/ui/components/select'
|
||||||
import { Separator } from '@authportal/ui/components/separator'
|
|
||||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||||
|
|
||||||
@@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) {
|
|||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DotSeparator() {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
|
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
|
||||||
return isAdmin ? (
|
return isAdmin ? (
|
||||||
<Badge variant="info-outline">Админ</Badge>
|
<Badge variant="info-outline">Админ</Badge>
|
||||||
@@ -710,15 +699,6 @@ export function AdminUsersGrid({
|
|||||||
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
||||||
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
|
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
|
||||||
|
|
||||||
const stats = useMemo(
|
|
||||||
() => ({
|
|
||||||
total: users.length,
|
|
||||||
admins: users.filter((u) => u.is_admin).length,
|
|
||||||
disabled: users.filter((u) => u.disabled).length,
|
|
||||||
}),
|
|
||||||
[users],
|
|
||||||
)
|
|
||||||
|
|
||||||
const filterFields: FilterField[] = useMemo(
|
const filterFields: FilterField[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -778,6 +758,16 @@ export function AdminUsersGrid({
|
|||||||
onDeactivate,
|
onDeactivate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onResetPasskeys,
|
onResetPasskeys,
|
||||||
|
}).map((col, i, all) => {
|
||||||
|
const pad = cellPad(i, all.length)
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
meta: {
|
||||||
|
...col.meta,
|
||||||
|
headerClassName: cn(col.meta?.headerClassName, pad),
|
||||||
|
cellClassName: cn(col.meta?.cellClassName, pad),
|
||||||
|
},
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
||||||
)
|
)
|
||||||
@@ -850,38 +840,17 @@ export function AdminUsersGrid({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
tableLayout={{
|
tableLayout={{
|
||||||
|
...DATA_GRID_LAYOUT,
|
||||||
columnsResizable: true,
|
columnsResizable: true,
|
||||||
columnsMovable: true,
|
columnsMovable: true,
|
||||||
columnsVisibility: true,
|
columnsVisibility: true,
|
||||||
headerSticky: true,
|
headerSticky: true,
|
||||||
dense: true,
|
|
||||||
}}
|
}}
|
||||||
|
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||||
>
|
>
|
||||||
<Frame spacing="sm" className="w-full">
|
<DataPageCard>
|
||||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
<DataPageToolbar
|
||||||
<div className="flex flex-col gap-0.5">
|
filterControl={
|
||||||
<FrameTitle id="page-heading" className="text-balance">
|
|
||||||
Пользователи
|
|
||||||
</FrameTitle>
|
|
||||||
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
|
|
||||||
<span>{stats.total} учётных записей</span>
|
|
||||||
<DotSeparator />
|
|
||||||
<span>{stats.admins} админов</span>
|
|
||||||
{stats.disabled > 0 ? (
|
|
||||||
<>
|
|
||||||
<DotSeparator />
|
|
||||||
<span>{stats.disabled} отключено</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</FrameDescription>
|
|
||||||
</div>
|
|
||||||
<Button type="button" size="default" onClick={onCreateClick}>
|
|
||||||
<UserPlusIcon aria-hidden="true" />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel className="p-0 shadow-none">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
|
||||||
<Filters
|
<Filters
|
||||||
query={query}
|
query={query}
|
||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
@@ -890,27 +859,18 @@ export function AdminUsersGrid({
|
|||||||
operatorLabels={FILTER_OPERATORS_RU}
|
operatorLabels={FILTER_OPERATORS_RU}
|
||||||
size="default"
|
size="default"
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
|
||||||
type="button"
|
|
||||||
size="default"
|
|
||||||
variant="outline"
|
|
||||||
aria-label="Фильтры"
|
|
||||||
>
|
|
||||||
<FilterIcon aria-hidden />
|
<FilterIcon aria-hidden />
|
||||||
Фильтры
|
Фильтры
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
}
|
||||||
|
countLabel={`${filteredData.length} пользователей`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
{showClearButton ? (
|
{showClearButton ? (
|
||||||
<Button
|
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
|
||||||
type="button"
|
|
||||||
size="default"
|
|
||||||
variant="outline"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={handleClearFilters}
|
|
||||||
disabled={gridLoading}
|
|
||||||
>
|
|
||||||
<FunnelXIcon aria-hidden />
|
<FunnelXIcon aria-hidden />
|
||||||
Сбросить
|
Сбросить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -918,42 +878,34 @@ export function AdminUsersGrid({
|
|||||||
<DataGridColumnVisibility
|
<DataGridColumnVisibility
|
||||||
table={table}
|
table={table}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
|
||||||
type="button"
|
|
||||||
size="default"
|
|
||||||
variant="outline"
|
|
||||||
aria-label="Настройки таблицы"
|
|
||||||
>
|
|
||||||
<Settings2Icon aria-hidden="true" />
|
<Settings2Icon aria-hidden="true" />
|
||||||
Колонки
|
Колонки
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button type="button" size="default" onClick={onCreateClick}>
|
||||||
</div>
|
<UserPlusIcon aria-hidden="true" />
|
||||||
<Separator />
|
Создать
|
||||||
{selectedCount > 0 ? (
|
</Button>
|
||||||
<>
|
|
||||||
<AdminUsersBulkActionBar
|
|
||||||
selectedCount={selectedCount}
|
|
||||||
roleValue={bulkRole}
|
|
||||||
onRoleChange={setBulkRole}
|
|
||||||
onChangeRole={handleChangeRole}
|
|
||||||
onDeactivate={handleBulkDeactivate}
|
|
||||||
onClear={handleClearSelection}
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
}
|
||||||
<DataGridScrollArea>
|
/>
|
||||||
<DataGridTable />
|
{selectedCount > 0 ? (
|
||||||
</DataGridScrollArea>
|
<AdminUsersBulkActionBar
|
||||||
<Separator />
|
selectedCount={selectedCount}
|
||||||
<FrameFooter>
|
roleValue={bulkRole}
|
||||||
<DataGridPagination sizes={[10, 20, 30]} />
|
onRoleChange={setBulkRole}
|
||||||
</FrameFooter>
|
onChangeRole={handleChangeRole}
|
||||||
</FramePanel>
|
onDeactivate={handleBulkDeactivate}
|
||||||
</Frame>
|
onClear={handleClearSelection}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
|
||||||
|
</DataPageCard>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user