feat(web): унифицировать таблицы по раскладке MikrotikManager v2
- поиск и фильтры страниц перенесены в шапку рамки рядом с заголовком, ListFiltersBar стал однострочным тулбаром - шапка таблицы с подложкой, строки без зебры (общий kitDataGridTableLayout) - у таблиц заголовки и подписи из досок макета - IP в списке VPS моноширинным шрифтом и без обрезки, компактная иконка в ячейке-названии Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
148ef8efb7
commit
a4a8b9562d
13 files changed
+147
-152
No files matched your search
@@ -48,7 +48,7 @@ export function DataGridNameCell({
|
||||
}) {
|
||||
return (
|
||||
<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} />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ListFiltersSearch({
|
||||
spellCheck = false,
|
||||
}: ListFiltersSearchProps) {
|
||||
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" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
@@ -140,30 +140,19 @@ export function ListFiltersBar({
|
||||
showReset,
|
||||
toggles,
|
||||
}: ListFiltersBarProps) {
|
||||
const hasMeta =
|
||||
chips?.length ||
|
||||
(shown != null && total != null) ||
|
||||
resultsSuffix ||
|
||||
(showReset && onReset)
|
||||
|
||||
// Однострочный тулбар для шапки Frame (MikrotikManager v2): контролы → поиск → сброс; чипы — строкой ниже.
|
||||
// «Показано N из M» показываем только когда фильтр что-то скрыл; общий счёт — в футере пагинации.
|
||||
const filtered = shown != null && total != null && shown !== total
|
||||
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}
|
||||
{controls || toggles ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{controls}
|
||||
{toggles}
|
||||
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||
</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}
|
||||
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||
{filtered ? <FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} /> : null}
|
||||
{chips?.length ? (
|
||||
<div className="basis-full">
|
||||
<FilterActiveChips chips={chips} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,10 @@ import { BLOCKING_MATRIX_GRID } from '../censorcheck/blocking-grid'
|
||||
import { GEO_MATRIX_GRID } from '../ipregion/geo-grid'
|
||||
|
||||
describe('kitDataGridTableLayout', () => {
|
||||
it('CRUD defaults: без bg-muted header и width fixed', () => {
|
||||
it('CRUD defaults: подложка шапки, без зебры, width fixed', () => {
|
||||
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.headerSticky).toBe(true)
|
||||
expect(layout.columnsPinnable).toBe(false)
|
||||
@@ -20,7 +21,8 @@ describe('kitDataGridTableLayout', () => {
|
||||
})
|
||||
expect(layout.width).toBe('auto')
|
||||
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 {
|
||||
dense: opts.dense ?? true,
|
||||
stripped: true,
|
||||
// MikrotikManager v2 / макет `gh` + `rowv`: подложка у шапки, строки без зебры, разделитель.
|
||||
stripped: false,
|
||||
rowBorder: true,
|
||||
headerSticky: true,
|
||||
headerBackground: false,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: opts.width ?? ('fixed' as const),
|
||||
columnsVisibility: opts.columnsVisibility ?? false,
|
||||
@@ -146,6 +147,8 @@ export interface FrameDataGridProps<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
/** Поиск / фильтры страницы — в шапке рамки рядом с заголовком (раскладка MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
columns: DataGridColumnDef<TData>[]
|
||||
data: TData[]
|
||||
/** Ключ строки — функция, возвращающая уникальный id. */
|
||||
@@ -199,25 +202,29 @@ export interface FrameDataGridProps<TData extends object> {
|
||||
columnPinControls?: boolean
|
||||
}
|
||||
|
||||
function DataGridSectionHeader({
|
||||
/** Шапка рамки: заголовок слева, справа тулбар (фильтры, поиск) и действия — переносятся строкой ниже на узком экране. */
|
||||
export function DataGridSectionHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
toolbar,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
if (!title && !description && !actions) return null
|
||||
if (!title && !description && !actions && !toolbar) return null
|
||||
|
||||
return (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<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-px">
|
||||
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{toolbar ? <div className="flex min-w-0 flex-wrap items-center gap-2">{toolbar}</div> : null}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
@@ -315,6 +322,7 @@ export function FrameDataGrid<TData extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
toolbar,
|
||||
columns,
|
||||
data,
|
||||
rowId,
|
||||
@@ -480,13 +488,13 @@ export function FrameDataGrid<TData extends object>({
|
||||
columnVisibilityAction
|
||||
)
|
||||
|
||||
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||||
const hasHeader = Boolean(title || description || actions || toolbar || showColumnVisibilityTrigger)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} toolbar={toolbar} />
|
||||
) : null}
|
||||
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
@@ -516,7 +524,7 @@ export function FrameDataGrid<TData extends object>({
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} toolbar={toolbar} />
|
||||
) : null}
|
||||
<FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -210,13 +210,11 @@ function ResourcePageSimple<T extends object>({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{toolbarExtra ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">{toolbarExtra}</div>
|
||||
) : null}
|
||||
<FrameDataGrid
|
||||
title={hideHeader ? undefined : title || undefined}
|
||||
description={hideHeader ? undefined : description}
|
||||
actions={hideHeader ? undefined : headerActions}
|
||||
toolbar={toolbarExtra}
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowId={getRowId}
|
||||
@@ -429,39 +427,48 @@ function ResourcePageFiltered<T extends object>({
|
||||
})}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
{/* Раскладка 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="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<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>
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{headerActions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{headerActions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
) : (
|
||||
<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 ? (
|
||||
@@ -477,41 +484,6 @@ function ResourcePageFiltered<T extends object>({
|
||||
</>
|
||||
) : 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}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
|
||||
@@ -444,17 +444,21 @@ function AccountsPage() {
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{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}
|
||||
<ResourcePage
|
||||
title="Аккаунты"
|
||||
description="Поиск по названию и логину"
|
||||
toolbarExtra={
|
||||
snapshot ? (
|
||||
<AccountFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
providers={snapshot.providers}
|
||||
shownCount={filteredAccounts.length}
|
||||
totalCount={snapshot.providerAccounts.length}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={filteredAccounts}
|
||||
getRowId={(a) => a.id}
|
||||
|
||||
@@ -237,6 +237,8 @@ function BalancePage() {
|
||||
]}
|
||||
/>
|
||||
<ResourcePage
|
||||
title="Движения по аккаунтам"
|
||||
description="Журнал баланса · удаление записи с подтверждением"
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={rows}
|
||||
getRowId={(r) => r.id}
|
||||
|
||||
@@ -247,6 +247,8 @@ function PaymentsPage() {
|
||||
]}
|
||||
/>
|
||||
<ResourcePage
|
||||
title="Платежи"
|
||||
description="Журнал платежей"
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={sorted}
|
||||
getRowId={(p) => p.id}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from '@/components/project-filters'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ProjectColorDot } from '@/components/project-color-dot'
|
||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||
import type { ProjectFormValues } from '@/lib/schemas'
|
||||
@@ -277,35 +276,37 @@ function ProjectsPage() {
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<ProjectFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
shownCount={rows.length}
|
||||
totalCount={allRows.length}
|
||||
/>
|
||||
{rows.length === 0 && hasActiveProjectFilters(filters) ? (
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
description="Измените фильтры или сбросьте их"
|
||||
action={
|
||||
{/* Пустой результат фильтра рисует сама рамка: поиск остаётся в шапке, чтобы его поправить. */}
|
||||
<ResourcePage
|
||||
title="Проекты"
|
||||
description="Клик открывает проект"
|
||||
toolbarExtra={
|
||||
<ProjectFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
shownCount={rows.length}
|
||||
totalCount={allRows.length}
|
||||
/>
|
||||
}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ResourcePage
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={rows}
|
||||
getRowId={(r) => r.id}
|
||||
onRowClick={(row) =>
|
||||
void navigate({
|
||||
to: '/projects/$projectId',
|
||||
params: { projectId: row.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
) : undefined
|
||||
}
|
||||
onRowClick={(row) =>
|
||||
void navigate({
|
||||
to: '/projects/$projectId',
|
||||
params: { projectId: row.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CrudListPage>
|
||||
|
||||
@@ -150,6 +150,8 @@ function ProvidersPage() {
|
||||
>
|
||||
{(snap) => (
|
||||
<ResourcePage
|
||||
title="Хостеры"
|
||||
description="Провайдеры и тип API"
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={snap.providers}
|
||||
getRowId={(p) => p.id}
|
||||
|
||||
@@ -99,6 +99,8 @@ function SyncJournalPage() {
|
||||
>
|
||||
{(snap) => (
|
||||
<ResourcePage
|
||||
title="Запуски синхронизации"
|
||||
description="Последние запуски по аккаунтам"
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={snap.syncLog ?? []}
|
||||
getRowId={(r) => r.id}
|
||||
|
||||
@@ -385,8 +385,10 @@ function TariffsPage() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{toolbar}
|
||||
<ResourcePage
|
||||
title="Тарифы"
|
||||
description="Прайс-листы хостеров по дата-центрам"
|
||||
toolbarExtra={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={filteredTariffs}
|
||||
getRowId={(t) => t.id}
|
||||
|
||||
@@ -344,6 +344,9 @@ function VpsPage() {
|
||||
key: 'ip',
|
||||
header: 'IP / DNS',
|
||||
icon: GlobeIcon,
|
||||
// Полный IPv4 + иконка не помещались в равную долю fixed-таблицы и обрезались.
|
||||
size: 240,
|
||||
minSize: 220,
|
||||
sortValue: (v) => v.ip || v.dns || '',
|
||||
cell: (v) => (
|
||||
<DataGridNameCell
|
||||
@@ -352,7 +355,7 @@ function VpsPage() {
|
||||
v.ip ? (
|
||||
<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 скопирован')}
|
||||
>
|
||||
{v.ip}
|
||||
@@ -654,17 +657,8 @@ function VpsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
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' })}
|
||||
/>
|
||||
// Один набор фильтров: в шапке таблицы (MikrotikManager v2), при группировке — над секциями.
|
||||
const filtersToolbar = (
|
||||
<VpsFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
@@ -680,9 +674,24 @@ function VpsPage() {
|
||||
columnVisibility={columnVisibility}
|
||||
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) => (
|
||||
<ResourcePage
|
||||
key={section.key}
|
||||
toolbarExtra={tableSections.length === 1 ? filtersToolbar : undefined}
|
||||
title={section.label ?? 'Инвентарь VPS'}
|
||||
description={
|
||||
section.label ? undefined : 'Поиск по IP, DNS, проекту, назначению, ОС'
|
||||
|
||||
Reference in new issue
Block a user