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 (
|
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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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, проекту, назначению, ОС'
|
||||||
|
|||||||
Reference in new issue
Block a user