feat(web): компактные KPI-карточки и унификация фильтров таблиц
Docker / build (push) Has been cancelled

Сделать метрики плотнее и информативнее, вынести общий toolbar фильтров с chips и счётчиком результатов, улучшить читаемость DataGrid через dense и зебру.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 18:40:26 +07:00
co-authored by Cursor
parent c80090ed08
commit 2e1ca9f2a7
16 changed files with 682 additions and 222 deletions
@@ -1,9 +1,11 @@
import { SearchIcon, XIcon } from 'lucide-react'
import { useMemo } from 'react'
import { Input } from '@cfdm/ui/components/input'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { SelectField } from '@/components/select-field'
import {
ListFiltersBar,
FilterToggleChip,
type FilterChip,
} from '@/components/list-filters-bar'
import {
type AccountFiltersState,
buildDefaultAccountFilters,
@@ -16,80 +18,105 @@ interface AccountFiltersToolbarProps {
filters: AccountFiltersState
onChange: (next: AccountFiltersState) => void
providers: Provider[]
shownCount: number
totalCount: number
}
export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) {
const active = hasActiveAccountFilters(filters)
export function AccountFiltersToolbar({
filters,
onChange,
providers,
shownCount,
totalCount,
}: AccountFiltersToolbarProps) {
const chips = useMemo((): FilterChip[] => {
const out: FilterChip[] = []
if (filters.search.trim()) {
out.push({
id: 'search',
label: `Поиск: ${filters.search.trim()}`,
onRemove: () => onChange({ ...filters, search: '' }),
})
}
if (filters.providerIds[0]) {
const provider = providers.find((p) => p.id === filters.providerIds[0])
out.push({
id: 'provider',
label: `Хостер: ${provider?.name ?? filters.providerIds[0]}`,
onRemove: () => onChange({ ...filters, providerIds: [] }),
})
}
if (filters.billingMode) {
out.push({
id: 'billing',
label: `Биллинг: ${billingModeLabel(filters.billingMode)}`,
onRemove: () => onChange({ ...filters, billingMode: '' }),
})
}
return out
}, [filters, onChange, providers])
const toggle = (key: 'syncableOnly' | 'issuesOnly' | 'lowBalanceOnly') => {
onChange({ ...filters, [key]: !filters[key] })
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1 sm:max-w-xs">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Поиск по названию или логину"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
<ListFiltersBar
search={{
value: filters.search,
onChange: (search) => onChange({ ...filters, search }),
placeholder: 'Поиск по названию или логину',
}}
controls={
<>
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
</div>
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
...filters,
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
})
}
options={[
{ value: 'monthly', label: billingModeLabel('monthly') },
{ value: 'daily', label: billingModeLabel('daily') },
]}
/>
{active ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange(buildDefaultAccountFilters())}
>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.syncableOnly}
onCheckedChange={(v) => onChange({ ...filters, syncableOnly: v === true })}
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
...filters,
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
})
}
options={[
{ value: 'monthly', label: billingModeLabel('monthly') },
{ value: 'daily', label: billingModeLabel('daily') },
]}
/>
<span>Готовы к синку</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.issuesOnly}
onCheckedChange={(v) => onChange({ ...filters, issuesOnly: v === true })}
</>
}
toggles={
<>
<FilterToggleChip
label="Готовы к синку"
active={filters.syncableOnly}
onClick={() => toggle('syncableOnly')}
/>
<span>С проблемами</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.lowBalanceOnly}
onCheckedChange={(v) => onChange({ ...filters, lowBalanceOnly: v === true })}
<FilterToggleChip
label="С проблемами"
active={filters.issuesOnly}
onClick={() => toggle('issuesOnly')}
/>
<span>Низкий баланс</span>
</label>
</div>
</div>
<FilterToggleChip
label="Низкий баланс"
active={filters.lowBalanceOnly}
onClick={() => toggle('lowBalanceOnly')}
/>
</>
}
chips={chips}
shown={shownCount}
total={totalCount}
showReset={hasActiveAccountFilters(filters)}
onReset={() => onChange(buildDefaultAccountFilters())}
/>
)
}
@@ -36,6 +36,21 @@ export function hasActiveAccountFilters(filters: AccountFiltersState): boolean {
)
}
export function matchesAccountFilterPreset(
filters: AccountFiltersState,
preset: Partial<AccountFiltersState>,
): boolean {
const expected = { ...buildDefaultAccountFilters(), ...preset }
return (
filters.search === expected.search &&
filters.providerIds.join(',') === expected.providerIds.join(',') &&
filters.billingMode === expected.billingMode &&
filters.syncableOnly === expected.syncableOnly &&
filters.issuesOnly === expected.issuesOnly &&
filters.lowBalanceOnly === expected.lowBalanceOnly
)
}
export function applyAccountFilters(
accounts: ProviderAccount[],
filters: AccountFiltersState,
+5 -1
View File
@@ -111,6 +111,7 @@ function DataGridCardBody<TData extends object>({
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
@@ -123,6 +124,9 @@ function DataGridCardBody<TData extends object>({
rowsDraggable: false,
rowsPinnable: false,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
>
{virtualization ? (
<>
@@ -156,7 +160,7 @@ export function DataGridCard<TData extends object>({
pagination,
pageSize = 10,
footerContent,
dense = false,
dense = true,
pinLastColumn = false,
initialSorting,
virtualization = false,
+5 -3
View File
@@ -8,9 +8,11 @@ export function dataGridCellStack(
className?: string,
) {
return (
<div className={cn('flex flex-col', className)}>
<span className="font-medium">{primary}</span>
{secondary ? <span className="text-xs text-muted-foreground">{secondary}</span> : null}
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
<span className="truncate font-medium">{primary}</span>
{secondary ? (
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
) : null}
</div>
)
}
@@ -12,3 +12,10 @@ export interface DataTableColumn<T> {
className?: string
headerClassName?: string
}
/** Унифицированные классы колонок для DataGridCard. */
export const COL = {
num: 'w-28 text-right tabular-nums',
date: 'w-32 text-right tabular-nums text-muted-foreground',
actions: 'w-24 text-right',
} as const
@@ -0,0 +1,171 @@
import type { ReactNode } from 'react'
import { SearchIcon, XIcon } from 'lucide-react'
import { Input } from '@cfdm/ui/components/input'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { cn } from '@cfdm/ui/lib/utils'
export interface FilterChip {
id: string
label: string
onRemove: () => void
}
interface ListFiltersSearchProps {
value: string
onChange: (value: string) => void
placeholder: string
className?: string
name?: string
autoComplete?: string
spellCheck?: boolean
}
export function ListFiltersSearch({
value,
onChange,
placeholder,
className,
name,
autoComplete = 'off',
spellCheck = false,
}: ListFiltersSearchProps) {
return (
<div className={cn('relative 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}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-8"
autoComplete={autoComplete}
name={name}
spellCheck={spellCheck}
/>
</div>
)
}
export function FilterActiveChips({ chips }: { chips: FilterChip[] }) {
if (chips.length === 0) return null
return (
<div className="flex flex-wrap items-center gap-1.5">
{chips.map((chip) => (
<Badge key={chip.id} variant="secondary" className="gap-1 pr-1 font-normal">
<span className="max-w-[12rem] truncate">{chip.label}</span>
<button
type="button"
onClick={chip.onRemove}
className="rounded-sm p-0.5 hover:bg-muted"
aria-label={`Убрать фильтр: ${chip.label}`}
>
<XIcon className="size-3" />
</button>
</Badge>
))}
</div>
)
}
export function FilterResultsCount({
shown,
total,
suffix,
}: {
shown: number
total: number
suffix?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
Показано {shown} из {total}
</span>
{suffix ? <span className="text-muted-foreground/80">{suffix}</span> : null}
</div>
)
}
export function FilterResetButton({ onClick, visible }: { onClick: () => void; visible: boolean }) {
if (!visible) return null
return (
<Button type="button" variant="outline" size="sm" onClick={onClick}>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
)
}
interface FilterToggleChipProps {
label: string
active: boolean
onClick: () => void
}
export function FilterToggleChip({ label, active, onClick }: FilterToggleChipProps) {
return (
<Button
type="button"
variant={active ? 'secondary' : 'outline'}
size="sm"
onClick={onClick}
className={cn(active && 'border-primary/40')}
>
{label}
</Button>
)
}
interface ListFiltersBarProps {
search?: ListFiltersSearchProps
controls?: ReactNode
chips?: FilterChip[]
shown?: number
total?: number
resultsSuffix?: ReactNode
onReset?: () => void
showReset?: boolean
toggles?: ReactNode
}
export function ListFiltersBar({
search,
controls,
chips,
shown,
total,
resultsSuffix,
onReset,
showReset,
toggles,
}: ListFiltersBarProps) {
const hasMeta =
chips?.length ||
(shown != null && total != null) ||
resultsSuffix ||
(showReset && onReset)
return (
<div className="flex flex-col gap-2">
{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}
</div>
) : null}
</div>
)
}
+27 -9
View File
@@ -7,7 +7,9 @@ export interface SectionCardItem {
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
badge?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
active?: boolean
onClick?: () => void
}
@@ -23,28 +25,44 @@ function sectionGridClass(count: number): string {
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
return 'sm:grid-cols-2 lg:grid-cols-3'
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-4', sectionGridClass(items.length), className)}>
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
<CardContent className="flex flex-col gap-1 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{item.label}</span>
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
{item.icon ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
{item.icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
</div>
<div className="flex min-w-0 items-baseline gap-1.5">
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
{item.hint ? (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
) : null}
</div>
</div>
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
</CardContent>
)
return (
<Card
key={typeof item.label === 'string' ? item.label : idx}
className={cn('gap-0', VARIANT_CLASS[item.variant ?? 'default'], clickable && 'cursor-pointer transition-colors hover:bg-muted/40')}
className={cn(
'gap-0',
VARIANT_CLASS[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
)}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
+3 -2
View File
@@ -6,8 +6,9 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
}))}
/>
)
+237 -106
View File
@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react'
import { SearchIcon, SlidersHorizontalIcon, SaveIcon, Trash2Icon, XIcon } from 'lucide-react'
import { SlidersHorizontalIcon, SaveIcon, Trash2Icon } from 'lucide-react'
import { Input } from '@cfdm/ui/components/input'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Label } from '@cfdm/ui/components/label'
@@ -14,6 +13,8 @@ import {
import { Slider } from '@cfdm/ui/components/slider'
import { PlusIcon } from 'lucide-react'
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
import {
Filters,
createFilter,
@@ -25,6 +26,7 @@ import {
import {
type VpsFiltersState,
buildDefaultVpsFilters,
hasActiveVpsFilters,
stateToActiveFilters,
loadFilterPresets,
saveFilterPresets,
@@ -43,6 +45,8 @@ interface VpsFiltersToolbarProps {
countryOptions: { value: string; label: string; code?: string }[]
cityOptions: { value: string; label: string }[]
projectNameOptions: string[]
shownCount: number
totalCount: number
}
const RU_I18N: FilterI18nConfig = {
@@ -165,6 +169,8 @@ export function VpsFiltersToolbar({
countryOptions,
cityOptions,
projectNameOptions,
shownCount,
totalCount,
}: VpsFiltersToolbarProps) {
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
@@ -308,7 +314,136 @@ export function VpsFiltersToolbar({
onChange(filtersToState(next, filters))
}
const hasActive = reuiFilters.length > 0 || filters.search || filters.groupByProject || filters.tableCompact
const chips = useMemo((): FilterChip[] => {
const out: FilterChip[] = []
const providerById = new Map(providers.map((p) => [p.id, p.name]))
const accountById = new Map(providerAccounts.map((a) => [a.id, a.name]))
if (filters.search) {
out.push({
id: 'search',
label: `Поиск: ${filters.search}`,
onRemove: () => onChange({ ...filters, search: '' }),
})
}
if (filters.providerId.length) {
const names = filters.providerId.map((id) => providerById.get(id) ?? id).join(', ')
out.push({
id: 'providerId',
label: `Хостер: ${names}`,
onRemove: () => onChange({ ...filters, providerId: [] }),
})
}
if (filters.providerAccountId.length) {
const names = filters.providerAccountId.map((id) => accountById.get(id) ?? id).join(', ')
out.push({
id: 'providerAccountId',
label: `Аккаунт: ${names}`,
onRemove: () => onChange({ ...filters, providerAccountId: [] }),
})
}
if (filters.country.length) {
out.push({
id: 'country',
label: `Страна: ${filters.country.join(', ')}`,
onRemove: () => onChange({ ...filters, country: [] }),
})
}
if (filters.city.length) {
out.push({
id: 'city',
label: `Город: ${filters.city.join(', ')}`,
onRemove: () => onChange({ ...filters, city: [] }),
})
}
if (filters.datacenter) {
out.push({
id: 'datacenter',
label: `ДЦ: ${filters.datacenter}`,
onRemove: () => onChange({ ...filters, datacenter: '' }),
})
}
if (filters.status.length) {
out.push({
id: 'status',
label: `Статус: ${filters.status.map(vpsStatusLabel).join(', ')}`,
onRemove: () => onChange({ ...filters, status: [] }),
})
}
if (filters.environment.length) {
out.push({
id: 'environment',
label: `Окружение: ${filters.environment.map(environmentLabel).join(', ')}`,
onRemove: () => onChange({ ...filters, environment: [] }),
})
}
if (filters.tariffType.length) {
out.push({
id: 'tariffType',
label: `Тариф: ${filters.tariffType.map(tariffTypeLabel).join(', ')}`,
onRemove: () => onChange({ ...filters, tariffType: [] }),
})
}
if (filters.monitoring.length) {
out.push({
id: 'monitoring',
label: `Мониторинг: ${filters.monitoring.join(', ')}`,
onRemove: () => onChange({ ...filters, monitoring: [] }),
})
}
if (filters.backup.length) {
out.push({
id: 'backup',
label: `Бэкап: ${filters.backup.join(', ')}`,
onRemove: () => onChange({ ...filters, backup: [] }),
})
}
if (filters.project.length) {
out.push({
id: 'project',
label: `Проект: ${filters.project.map((p) => (p === '__none__' ? 'Без проекта' : p)).join(', ')}`,
onRemove: () => onChange({ ...filters, project: [] }),
})
}
if (filters.minVcpu != null) {
out.push({
id: 'minVcpu',
label: `vCPU ≥ ${filters.minVcpu}`,
onRemove: () => onChange({ ...filters, minVcpu: null }),
})
}
if (filters.minRamGb != null) {
out.push({
id: 'minRamGb',
label: `RAM ≥ ${filters.minRamGb} GB`,
onRemove: () => onChange({ ...filters, minRamGb: null }),
})
}
if (filters.minDiskGb != null) {
out.push({
id: 'minDiskGb',
label: `Disk ≥ ${filters.minDiskGb} GB`,
onRemove: () => onChange({ ...filters, minDiskGb: null }),
})
}
if (filters.groupByProject) {
out.push({
id: 'groupByProject',
label: 'Группировка по проекту',
onRemove: () => onChange({ ...filters, groupByProject: false }),
})
}
if (filters.tableCompact) {
out.push({
id: 'tableCompact',
label: 'Компактная таблица',
onRemove: () => onChange({ ...filters, tableCompact: false }),
})
}
return out
}, [filters, onChange, providers, providerAccounts])
const hasActive = hasActiveVpsFilters(filters)
const savePreset = () => {
const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`)
@@ -331,113 +466,109 @@ export function VpsFiltersToolbar({
const reset = () => onChange(buildDefaultVpsFilters())
return (
<div className="flex flex-col gap-2">
<div className="relative w-full">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск: IP, DNS, проект, назначение, ОС"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
className="pl-8"
autoComplete="off"
name="vps-inventory-search"
spellCheck={false}
/>
</div>
<ListFiltersBar
search={{
value: filters.search,
onChange: (search) => onChange({ ...filters, search }),
placeholder: 'Поиск: IP, DNS, проект, назначение, ОС',
name: 'vps-inventory-search',
}}
controls={
<>
<Filters
filters={reuiFilters as unknown as Filter[]}
fields={fields as unknown as FilterFieldConfig[]}
onChange={handleFiltersChange}
i18n={RU_I18N}
size="sm"
allowMultiple={false}
trigger={
<Button variant="outline" size="sm">
<PlusIcon data-icon="inline-start" />
Фильтр
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Filters
filters={reuiFilters as unknown as Filter[]}
fields={fields as unknown as FilterFieldConfig[]}
onChange={handleFiltersChange}
i18n={RU_I18N}
size="sm"
allowMultiple={false}
trigger={
<Button variant="outline" size="sm">
<PlusIcon data-icon="inline-start" />
Фильтр
</Button>
}
/>
<Popover>
<PopoverTrigger
render={
<Button variant="ghost" size="sm">
<SlidersHorizontalIcon data-icon="inline-start" />
Вид
</Button>
}
/>
<PopoverContent align="end" className="w-64 p-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">Отображение</Label>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.groupByProject}
onCheckedChange={(v) => onChange({ ...filters, groupByProject: Boolean(v) })}
/>
<span className="text-sm">Группировать по проекту</span>
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.tableCompact}
onCheckedChange={(v) => onChange({ ...filters, tableCompact: Boolean(v) })}
/>
<span className="text-sm">Компактная таблица</span>
</label>
</div>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Пресеты</Label>
<Button variant="ghost" size="sm" onClick={savePreset} className="h-7 px-2">
<SaveIcon className="size-3.5" />
Сохранить
<Popover>
<PopoverTrigger
render={
<Button variant="ghost" size="sm">
<SlidersHorizontalIcon data-icon="inline-start" />
Вид
</Button>
</div>
{presets.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет сохранённых пресетов</p>
) : (
<div className="flex flex-col gap-1">
{presets.map((p) => (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-accent">
<button
type="button"
onClick={() => applyPreset(p)}
className="flex-1 truncate text-start text-sm"
>
{p.name}
</button>
<button
type="button"
onClick={() => deletePreset(p.name)}
aria-label="Удалить пресет"
className="text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="size-3.5" />
</button>
</div>
))}
}
/>
<PopoverContent align="end" className="w-64 p-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">Отображение</Label>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.groupByProject}
onCheckedChange={(v) => onChange({ ...filters, groupByProject: Boolean(v) })}
/>
<span className="text-sm">Группировать по проекту</span>
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.tableCompact}
onCheckedChange={(v) => onChange({ ...filters, tableCompact: Boolean(v) })}
/>
<span className="text-sm">Компактная таблица</span>
</label>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{hasActive ? (
<Button variant="ghost" size="sm" onClick={reset}>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Пресеты</Label>
<Button variant="ghost" size="sm" onClick={savePreset} className="h-7 px-2">
<SaveIcon className="size-3.5" />
Сохранить
</Button>
</div>
{presets.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет сохранённых пресетов</p>
) : (
<div className="flex flex-col gap-1">
{presets.map((p) => (
<div
key={p.name}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-accent"
>
<button
type="button"
onClick={() => applyPreset(p)}
className="flex-1 truncate text-start text-sm"
>
{p.name}
</button>
<button
type="button"
onClick={() => deletePreset(p.name)}
aria-label="Удалить пресет"
className="text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</>
}
chips={chips}
shown={shownCount}
total={totalCount}
showReset={hasActive}
onReset={reset}
/>
)
}
+10 -10
View File
@@ -219,17 +219,17 @@ export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] {
export function countActiveFilters(filters: VpsFiltersState): number {
let n = 0
if (filters.search) n++
n += filters.providerId.length
n += filters.providerAccountId.length
n += filters.country.length
n += filters.city.length
if (filters.providerId.length) n++
if (filters.providerAccountId.length) n++
if (filters.country.length) n++
if (filters.city.length) n++
if (filters.datacenter) n++
n += filters.status.length
n += filters.environment.length
n += filters.tariffType.length
n += filters.monitoring.length
n += filters.backup.length
n += filters.project.length
if (filters.status.length) n++
if (filters.environment.length) n++
if (filters.tariffType.length) n++
if (filters.monitoring.length) n++
if (filters.backup.length) n++
if (filters.project.length) n++
if (filters.minVcpu != null) n++
if (filters.minRamGb != null) n++
if (filters.minDiskGb != null) n++