feat(web): компактные KPI-карточки и унификация фильтров таблиц
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Сделать метрики плотнее и информативнее, вынести общий toolbar фильтров с chips и счётчиком результатов, улучшить читаемость DataGrid через dense и зебру. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`} />,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user