feat(web): унифицировать таблицы по раскладке MikrotikManager v2

- поиск и фильтры страниц перенесены в шапку рамки рядом с заголовком,
  ListFiltersBar стал однострочным тулбаром
- шапка таблицы с подложкой, строки без зебры (общий kitDataGridTableLayout)
- у таблиц заголовки и подписи из досок макета
- IP в списке VPS моноширинным шрифтом и без обрезки, компактная
  иконка в ячейке-названии

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-07 03:06:21 +07:00
1 parent 148ef8efb7
commit a4a8b9562d
13 files changed
+147 -152

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ export function DataGridNameCell({
}) { }) {
return ( return (
<div className={cn('flex min-w-0 items-center gap-2.5', className)}> <div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<IconTile variant="elevated" className="size-10.5" aria-hidden> <IconTile variant="elevated" className="size-8 shrink-0" aria-hidden>
<Icon className={iconClassName} /> <Icon className={iconClassName} />
</IconTile> </IconTile>
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
+12 -23
View File
@@ -32,7 +32,7 @@ export function ListFiltersSearch({
spellCheck = false, spellCheck = false,
}: ListFiltersSearchProps) { }: ListFiltersSearchProps) {
return ( return (
<div className={cn('relative w-full', className)}> <div className={cn('relative w-[220px] max-w-full', className)}>
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> <SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder={placeholder} placeholder={placeholder}
@@ -140,30 +140,19 @@ export function ListFiltersBar({
showReset, showReset,
toggles, toggles,
}: ListFiltersBarProps) { }: ListFiltersBarProps) {
const hasMeta = // Однострочный тулбар для шапки Frame (MikrotikManager v2): контролы → поиск → сброс; чипы — строкой ниже.
chips?.length || // «Показано N из M» показываем только когда фильтр что-то скрыл; общий счёт — в футере пагинации.
(shown != null && total != null) || const filtered = shown != null && total != null && shown !== total
resultsSuffix ||
(showReset && onReset)
return ( return (
<div className="flex flex-col gap-2"> <div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{controls}
{toggles}
{search ? <ListFiltersSearch {...search} /> : null} {search ? <ListFiltersSearch {...search} /> : null}
{controls || toggles ? ( {onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
<div className="flex flex-wrap items-center gap-2"> {filtered ? <FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} /> : null}
{controls} {chips?.length ? (
{toggles} <div className="basis-full">
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null} <FilterActiveChips chips={chips} />
</div>
) : null}
{hasMeta ? (
<div className="flex flex-col gap-1.5">
{chips?.length ? <FilterActiveChips chips={chips} /> : null}
{shown != null && total != null ? (
<FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} />
) : resultsSuffix ? (
<div className="text-xs text-muted-foreground">{resultsSuffix}</div>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -5,9 +5,10 @@ import { BLOCKING_MATRIX_GRID } from '../censorcheck/blocking-grid'
import { GEO_MATRIX_GRID } from '../ipregion/geo-grid' import { GEO_MATRIX_GRID } from '../ipregion/geo-grid'
describe('kitDataGridTableLayout', () => { describe('kitDataGridTableLayout', () => {
it('CRUD defaults: без bg-muted header и width fixed', () => { it('CRUD defaults: подложка шапки, без зебры, width fixed', () => {
const layout = kitDataGridTableLayout() const layout = kitDataGridTableLayout()
expect(layout.headerBackground).toBe(false) expect(layout.headerBackground).toBe(true)
expect(layout.stripped).toBe(false)
expect(layout.width).toBe('fixed') expect(layout.width).toBe('fixed')
expect(layout.headerSticky).toBe(true) expect(layout.headerSticky).toBe(true)
expect(layout.columnsPinnable).toBe(false) expect(layout.columnsPinnable).toBe(false)
@@ -20,7 +21,8 @@ describe('kitDataGridTableLayout', () => {
}) })
expect(layout.width).toBe('auto') expect(layout.width).toBe('auto')
expect(layout.columnsPinnable).toBe(true) expect(layout.columnsPinnable).toBe(true)
expect(layout.headerBackground).toBe(false) expect(layout.headerBackground).toBe(true)
expect(layout.stripped).toBe(false)
}) })
}) })
@@ -105,10 +105,11 @@ export function kitDataGridTableLayout(opts: {
} = {}) { } = {}) {
return { return {
dense: opts.dense ?? true, dense: opts.dense ?? true,
stripped: true, // MikrotikManager v2 / макет `gh` + `rowv`: подложка у шапки, строки без зебры, разделитель.
stripped: false,
rowBorder: true, rowBorder: true,
headerSticky: true, headerSticky: true,
headerBackground: false, headerBackground: true,
headerBorder: true, headerBorder: true,
width: opts.width ?? ('fixed' as const), width: opts.width ?? ('fixed' as const),
columnsVisibility: opts.columnsVisibility ?? false, columnsVisibility: opts.columnsVisibility ?? false,
@@ -146,6 +147,8 @@ export interface FrameDataGridProps<TData extends object> {
title?: ReactNode title?: ReactNode
description?: ReactNode description?: ReactNode
actions?: ReactNode actions?: ReactNode
/** Поиск / фильтры страницы — в шапке рамки рядом с заголовком (раскладка MikrotikManager v2). */
toolbar?: ReactNode
columns: DataGridColumnDef<TData>[] columns: DataGridColumnDef<TData>[]
data: TData[] data: TData[]
/** Ключ строки — функция, возвращающая уникальный id. */ /** Ключ строки — функция, возвращающая уникальный id. */
@@ -199,25 +202,29 @@ export interface FrameDataGridProps<TData extends object> {
columnPinControls?: boolean columnPinControls?: boolean
} }
function DataGridSectionHeader({ /** Шапка рамки: заголовок слева, справа тулбар (фильтры, поиск) и действия — переносятся строкой ниже на узком экране. */
export function DataGridSectionHeader({
title, title,
description, description,
actions, actions,
toolbar,
}: { }: {
title?: ReactNode title?: ReactNode
description?: ReactNode description?: ReactNode
actions?: ReactNode actions?: ReactNode
toolbar?: ReactNode
}) { }) {
if (!title && !description && !actions) return null if (!title && !description && !actions && !toolbar) return null
return ( return (
<FrameHeader className="flex-row items-start justify-between gap-3"> <FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-col gap-px"> <div className="flex min-w-0 flex-1 basis-52 flex-col gap-px">
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null} {title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
{description ? ( {description ? (
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription> <FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
) : null} ) : null}
</div> </div>
{toolbar ? <div className="flex min-w-0 flex-wrap items-center gap-2">{toolbar}</div> : null}
{actions ? ( {actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null} ) : null}
@@ -315,6 +322,7 @@ export function FrameDataGrid<TData extends object>({
title, title,
description, description,
actions, actions,
toolbar,
columns, columns,
data, data,
rowId, rowId,
@@ -480,13 +488,13 @@ export function FrameDataGrid<TData extends object>({
columnVisibilityAction columnVisibilityAction
) )
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger) const hasHeader = Boolean(title || description || actions || toolbar || showColumnVisibilityTrigger)
if (data.length === 0) { if (data.length === 0) {
return ( return (
<Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}> <Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}>
{hasHeader ? ( {hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} /> <DataGridSectionHeader title={title} description={description} actions={headerActions} toolbar={toolbar} />
) : null} ) : null}
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center"> <FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} /> <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
@@ -516,7 +524,7 @@ export function FrameDataGrid<TData extends object>({
return ( return (
<Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}> <Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}>
{hasHeader ? ( {hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} /> <DataGridSectionHeader title={title} description={description} actions={headerActions} toolbar={toolbar} />
) : null} ) : null}
<FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel> <FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel>
</Frame> </Frame>
@@ -210,13 +210,11 @@ function ResourcePageSimple<T extends object>({
return ( return (
<div className="w-full"> <div className="w-full">
{toolbarExtra ? (
<div className="mb-3 flex flex-wrap items-center gap-2">{toolbarExtra}</div>
) : null}
<FrameDataGrid <FrameDataGrid
title={hideHeader ? undefined : title || undefined} title={hideHeader ? undefined : title || undefined}
description={hideHeader ? undefined : description} description={hideHeader ? undefined : description}
actions={hideHeader ? undefined : headerActions} actions={hideHeader ? undefined : headerActions}
toolbar={toolbarExtra}
columns={columns} columns={columns}
data={data} data={data}
rowId={getRowId} rowId={getRowId}
@@ -429,39 +427,48 @@ function ResourcePageFiltered<T extends object>({
})} })}
> >
<Frame dense variant="default" spacing="sm" className="w-full"> <Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? ( {/* Раскладка MikrotikManager v2: заголовок, затем фильтры/поиск/сброс и действия в одной шапке. */}
<FrameHeader className="flex-row items-start justify-between gap-3"> <FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-col gap-px"> {!hideHeader ? (
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle> <FrameTitle className="text-balance">{title}</FrameTitle>
{description ? ( {description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty"> <FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null} ) : null}
</div> </div>
{headerActions ? ( ) : (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2"> <div className="flex-1" />
{headerActions} )}
</div> {showFilters ? (
) : null} <Filters
</FrameHeader> filters={filters}
) : null} 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!"> <FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? ( {countedTabs.length > 0 ? (
@@ -477,41 +484,6 @@ function ResourcePageFiltered<T extends object>({
</> </>
) : null} ) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
{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" />
Фильтры
</Button>
}
/>
) : (
<div />
)}
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{showFilters && onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
<DataGridContainer border={false}> <DataGridContainer border={false}>
<DataGridTable /> <DataGridTable />
</DataGridContainer> </DataGridContainer>
+13 -9
View File
@@ -444,17 +444,21 @@ function AccountsPage() {
{() => ( {() => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{snapshot ? <KpiStatGrid items={summaryCards} /> : null} {snapshot ? <KpiStatGrid items={summaryCards} /> : null}
{snapshot ? (
<AccountFiltersToolbar
filters={filters}
onChange={setFilters}
providers={snapshot.providers}
shownCount={filteredAccounts.length}
totalCount={snapshot.providerAccounts.length}
/>
) : null}
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null} {health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
<ResourcePage <ResourcePage
title="Аккаунты"
description="Поиск по названию и логину"
toolbarExtra={
snapshot ? (
<AccountFiltersToolbar
filters={filters}
onChange={setFilters}
providers={snapshot.providers}
shownCount={filteredAccounts.length}
totalCount={snapshot.providerAccounts.length}
/>
) : undefined
}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={filteredAccounts} data={filteredAccounts}
getRowId={(a) => a.id} getRowId={(a) => a.id}
+2
View File
@@ -237,6 +237,8 @@ function BalancePage() {
]} ]}
/> />
<ResourcePage <ResourcePage
title="Движения по аккаунтам"
description="Журнал баланса · удаление записи с подтверждением"
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={rows} data={rows}
getRowId={(r) => r.id} getRowId={(r) => r.id}
+2
View File
@@ -247,6 +247,8 @@ function PaymentsPage() {
]} ]}
/> />
<ResourcePage <ResourcePage
title="Платежи"
description="Журнал платежей"
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={sorted} data={sorted}
getRowId={(p) => p.id} getRowId={(p) => p.id}
+28 -27
View File
@@ -26,7 +26,6 @@ import {
} from '@/components/project-filters' } from '@/components/project-filters'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import { ProjectColorDot } from '@/components/project-color-dot' import { ProjectColorDot } from '@/components/project-color-dot'
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet' import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
import type { ProjectFormValues } from '@/lib/schemas' import type { ProjectFormValues } from '@/lib/schemas'
@@ -277,35 +276,37 @@ function ProjectsPage() {
]} ]}
/> />
) : null} ) : null}
<ProjectFiltersToolbar {/* Пустой результат фильтра рисует сама рамка: поиск остаётся в шапке, чтобы его поправить. */}
filters={filters} <ResourcePage
onChange={setFilters} title="Проекты"
shownCount={rows.length} description="Клик открывает проект"
totalCount={allRows.length} toolbarExtra={
/> <ProjectFiltersToolbar
{rows.length === 0 && hasActiveProjectFilters(filters) ? ( filters={filters}
<EmptyState onChange={setFilters}
title="Ничего не найдено" shownCount={rows.length}
description="Измените фильтры или сбросьте их" totalCount={allRows.length}
action={ />
}
columns={columnDefFromDataGrid(columns)}
data={rows}
getRowId={(r) => r.id}
emptyTitle={hasActiveProjectFilters(filters) ? 'Ничего не найдено' : 'Нет проектов'}
emptyDescription={hasActiveProjectFilters(filters) ? 'Измените фильтры или сбросьте их' : undefined}
emptyAction={
hasActiveProjectFilters(filters) ? (
<Button variant="outline" onClick={() => setFilters(buildDefaultProjectFilters())}> <Button variant="outline" onClick={() => setFilters(buildDefaultProjectFilters())}>
Сбросить фильтры Сбросить фильтры
</Button> </Button>
} ) : undefined
/> }
) : ( onRowClick={(row) =>
<ResourcePage void navigate({
columns={columnDefFromDataGrid(columns)} to: '/projects/$projectId',
data={rows} params: { projectId: row.id },
getRowId={(r) => r.id} })
onRowClick={(row) => }
void navigate({ />
to: '/projects/$projectId',
params: { projectId: row.id },
})
}
/>
)}
</div> </div>
)} )}
</CrudListPage> </CrudListPage>
+2
View File
@@ -150,6 +150,8 @@ function ProvidersPage() {
> >
{(snap) => ( {(snap) => (
<ResourcePage <ResourcePage
title="Хостеры"
description="Провайдеры и тип API"
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={snap.providers} data={snap.providers}
getRowId={(p) => p.id} getRowId={(p) => p.id}
@@ -99,6 +99,8 @@ function SyncJournalPage() {
> >
{(snap) => ( {(snap) => (
<ResourcePage <ResourcePage
title="Запуски синхронизации"
description="Последние запуски по аккаунтам"
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={snap.syncLog ?? []} data={snap.syncLog ?? []}
getRowId={(r) => r.id} getRowId={(r) => r.id}
+3 -1
View File
@@ -385,8 +385,10 @@ function TariffsPage() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
{toolbar}
<ResourcePage <ResourcePage
title="Тарифы"
description="Прайс-листы хостеров по дата-центрам"
toolbarExtra={toolbar}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={filteredTariffs} data={filteredTariffs}
getRowId={(t) => t.id} getRowId={(t) => t.id}
+21 -12
View File
@@ -344,6 +344,9 @@ function VpsPage() {
key: 'ip', key: 'ip',
header: 'IP / DNS', header: 'IP / DNS',
icon: GlobeIcon, icon: GlobeIcon,
// Полный IPv4 + иконка не помещались в равную долю fixed-таблицы и обрезались.
size: 240,
minSize: 220,
sortValue: (v) => v.ip || v.dns || '', sortValue: (v) => v.ip || v.dns || '',
cell: (v) => ( cell: (v) => (
<DataGridNameCell <DataGridNameCell
@@ -352,7 +355,7 @@ function VpsPage() {
v.ip ? ( v.ip ? (
<button <button
type="button" type="button"
className="truncate text-left font-medium hover:underline" className="text-left font-mono font-medium tabular-nums whitespace-nowrap hover:underline"
onClick={() => void copyText(v.ip, 'IP скопирован')} onClick={() => void copyText(v.ip, 'IP скопирован')}
> >
{v.ip} {v.ip}
@@ -654,17 +657,8 @@ function VpsPage() {
) )
} }
return ( // Один набор фильтров: в шапке таблицы (MikrotikManager v2), при группировке — над секциями.
<div className="flex flex-col gap-4"> const filtersToolbar = (
{health ? <HealthModeBanner health={health} exitTo="/vps" /> : null}
<VpsBulkToolbar
selectedCount={selectedIds.length}
projectOptions={projectNameOptions}
busy={bulkMutation.isPending}
onSetStatus={(status) => bulkMutation.mutate({ ids: selectedIds, action: 'status', value: status })}
onSetProject={(project) => bulkMutation.mutate({ ids: selectedIds, action: 'project', value: project })}
onDelete={() => bulkMutation.mutate({ ids: selectedIds, action: 'delete' })}
/>
<VpsFiltersToolbar <VpsFiltersToolbar
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
@@ -680,9 +674,24 @@ function VpsPage() {
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange} onColumnVisibilityChange={handleColumnVisibilityChange}
/> />
)
return (
<div className="flex flex-col gap-4">
{health ? <HealthModeBanner health={health} exitTo="/vps" /> : null}
<VpsBulkToolbar
selectedCount={selectedIds.length}
projectOptions={projectNameOptions}
busy={bulkMutation.isPending}
onSetStatus={(status) => bulkMutation.mutate({ ids: selectedIds, action: 'status', value: status })}
onSetProject={(project) => bulkMutation.mutate({ ids: selectedIds, action: 'project', value: project })}
onDelete={() => bulkMutation.mutate({ ids: selectedIds, action: 'delete' })}
/>
{tableSections.length > 1 ? filtersToolbar : null}
{tableSections.map((section) => ( {tableSections.map((section) => (
<ResourcePage <ResourcePage
key={section.key} key={section.key}
toolbarExtra={tableSections.length === 1 ? filtersToolbar : undefined}
title={section.label ?? 'Инвентарь VPS'} title={section.label ?? 'Инвентарь VPS'}
description={ description={
section.label ? undefined : 'Поиск по IP, DNS, проекту, назначению, ОС' section.label ? undefined : 'Поиск по IP, DNS, проекту, назначению, ОС'