feat(web): фильтры на странице VPS — полный набор + пресеты

Восстановлены фильтры, потерянные при миграции на shadcn:
поиск, хостер/аккаунт, проект (с опцией «Без проекта»), статус,
окружение, тип тарифа, мониторинг/бэкап, страна/город/дата-центр,
минимумы vCPU/RAM/Disk, группировка по проекту, компактная таблица.
Пресеты сохраняются в localStorage. Добавлен shadcn-компонент checkbox.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-26 17:31:45 +07:00
co-authored by Cursor
parent 8b38021200
commit 525eff0d09
4 changed files with 566 additions and 7 deletions
+458
View File
@@ -0,0 +1,458 @@
import { useMemo } from 'react'
import { SearchIcon, FilterIcon, XIcon, SaveIcon, TrashIcon } 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 { Card, CardContent } from '@cfdm/ui/components/card'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Label } from '@cfdm/ui/components/label'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { SelectField } from '@/components/select-field'
import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
export interface VpsFiltersState {
search: string
providerId: string
providerAccountId: string
country: string
city: string
datacenter: string
status: string // 'all' | VpsStatus
environment: string // 'all' | env
tariffType: string // 'all' | TariffType
monitoring: string // 'all' | 'on' | 'off'
backup: string // 'all' | 'on' | 'off'
minVcpu: string
minRamGb: string
minDiskGb: string
project: string // '' | '__none__' | name
groupByProject: boolean
tableCompact: boolean
}
export function buildDefaultVpsFilters(): VpsFiltersState {
return {
search: '',
providerId: '',
providerAccountId: '',
country: '',
city: '',
datacenter: '',
status: 'all',
environment: 'all',
tariffType: 'all',
monitoring: 'all',
backup: 'all',
minVcpu: '',
minRamGb: '',
minDiskGb: '',
project: '',
groupByProject: false,
tableCompact: false,
}
}
const ALL = 'all'
const STATUS_OPTIONS = [
{ value: ALL, label: 'Все статусы' },
{ value: 'active', label: vpsStatusLabel('active') },
{ value: 'paused', label: vpsStatusLabel('paused') },
{ value: 'archived', label: vpsStatusLabel('archived') },
]
const ENV_OPTIONS = [
{ value: ALL, label: 'Все окружения' },
{ value: 'prod', label: environmentLabel('prod') },
{ value: 'dev', label: environmentLabel('dev') },
{ value: 'staging', label: environmentLabel('staging') },
]
const TARIFF_OPTIONS = [
{ value: ALL, label: 'Все тарифы' },
{ value: 'monthly', label: tariffTypeLabel('monthly') },
{ value: 'daily', label: tariffTypeLabel('daily') },
]
const ON_OFF_OPTIONS = [
{ value: ALL, label: 'Любое' },
{ value: 'on', label: 'Включено' },
{ value: 'off', label: 'Выключено' },
]
const PRESETS_KEY = 'vps-tracker:vps-filter-presets'
export interface VpsFilterPreset {
name: string
filters: VpsFiltersState
}
export function loadFilterPresets(): VpsFilterPreset[] {
try {
const raw = localStorage.getItem(PRESETS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function saveFilterPresets(presets: VpsFilterPreset[]): void {
try {
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets))
} catch {
/* ignore */
}
}
export function applyVpsFilters(items: Vps[], filters: VpsFiltersState): Vps[] {
const search = filters.search.toLowerCase()
const minVcpu = Number(filters.minVcpu || 0)
const minRamGb = Number(filters.minRamGb || 0)
const minDiskGb = Number(filters.minDiskGb || 0)
return items.filter((item) => {
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
if (
search &&
!item.ip?.toLowerCase().includes(search) &&
!item.dns?.toLowerCase().includes(search) &&
!item.ipv6?.toLowerCase().includes(search) &&
!extraIps.toLowerCase().includes(search) &&
!item.project?.toLowerCase().includes(search) &&
!item.purpose?.toLowerCase().includes(search) &&
!item.os?.toLowerCase().includes(search)
)
return false
if (filters.providerId && item.providerId !== filters.providerId) return false
if (filters.providerAccountId && item.providerAccountId !== filters.providerAccountId) return false
if (filters.country && !item.country?.toLowerCase().includes(filters.country.toLowerCase())) return false
if (filters.city && !item.city?.toLowerCase().includes(filters.city.toLowerCase())) return false
if (filters.datacenter && !item.datacenter?.toLowerCase().includes(filters.datacenter.toLowerCase())) return false
if (filters.status !== ALL && item.status !== filters.status) return false
if (filters.environment !== ALL && item.environment !== filters.environment) return false
if (filters.tariffType !== ALL && item.tariffType !== filters.tariffType) return false
if (filters.monitoring !== ALL) {
const on = filters.monitoring === 'on'
if (on !== Boolean(item.monitoringEnabled)) return false
}
if (filters.backup !== ALL) {
const on = filters.backup === 'on'
if (on !== Boolean(item.backupEnabled)) return false
}
if (minVcpu && Number(item.vcpu || 0) < minVcpu) return false
if (minRamGb && Number(item.ramGb || 0) < minRamGb) return false
if (minDiskGb && Number(item.diskGb || 0) < minDiskGb) return false
const proj = (item.project || '').trim()
if (filters.project) {
if (filters.project === '__none__' ? proj : proj !== filters.project) return false
}
return true
})
}
export function countActiveFilters(filters: VpsFiltersState): number {
let n = 0
if (filters.search) n++
if (filters.providerId) n++
if (filters.providerAccountId) n++
if (filters.country) n++
if (filters.city) n++
if (filters.datacenter) n++
if (filters.status !== ALL) n++
if (filters.environment !== ALL) n++
if (filters.tariffType !== ALL) n++
if (filters.monitoring !== ALL) n++
if (filters.backup !== ALL) n++
if (filters.minVcpu) n++
if (filters.minRamGb) n++
if (filters.minDiskGb) n++
if (filters.project) n++
return n
}
interface VpsFiltersProps {
filters: VpsFiltersState
onChange: (next: VpsFiltersState) => void
providers: Provider[]
providerAccounts: ProviderAccount[]
projectNameOptions: string[]
presets: VpsFilterPreset[]
onPresetsChange: (presets: VpsFilterPreset[]) => void
}
export function VpsFilters({
filters,
onChange,
providers,
providerAccounts,
projectNameOptions,
presets,
onPresetsChange,
}: VpsFiltersProps) {
const update = <K extends keyof VpsFiltersState>(key: K, value: VpsFiltersState[K]) =>
onChange({ ...filters, [key]: value })
const accountOptions = useMemo(
() =>
providerAccounts.filter(
(a) => !filters.providerId || a.providerId === filters.providerId,
),
[providerAccounts, filters.providerId],
)
const activeCount = countActiveFilters(filters)
const hasFilters = activeCount > 0 || filters.groupByProject || filters.tableCompact
const savePreset = () => {
const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`)
if (!name) return
const next = [...presets.filter((p) => p.name !== name), { name, filters }]
onPresetsChange(next)
saveFilterPresets(next)
}
const applyPreset = (preset: VpsFilterPreset) => {
onChange({ ...buildDefaultVpsFilters(), ...preset.filters })
}
const deletePreset = (name: string) => {
const next = presets.filter((p) => p.name !== name)
onPresetsChange(next)
saveFilterPresets(next)
}
const reset = () => onChange(buildDefaultVpsFilters())
return (
<Card>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<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) => update('search', e.target.value)}
className="pl-8"
/>
</div>
<SelectField
triggerId="flt-status"
value={filters.status}
onValueChange={(v) => update('status', v ?? ALL)}
options={STATUS_OPTIONS}
triggerClassName="w-44"
/>
<SelectField
triggerId="flt-provider"
placeholder="Все хостеры"
value={filters.providerId || null}
onValueChange={(v) =>
onChange({ ...filters, providerId: v ?? '', providerAccountId: '' })
}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
triggerClassName="w-44"
/>
<SelectField
triggerId="flt-account"
placeholder="Все аккаунты"
value={filters.providerAccountId || null}
onValueChange={(v) => update('providerAccountId', v ?? '')}
options={accountOptions.map((a) => ({ value: a.id, label: a.name }))}
triggerClassName="w-44"
/>
<SelectField
triggerId="flt-project"
placeholder="Все проекты"
value={filters.project || null}
onValueChange={(v) => update('project', v ?? '')}
options={[
{ value: '__none__', label: 'Без проекта' },
...projectNameOptions.map((p) => ({ value: p, label: p })),
]}
triggerClassName="w-44"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
<FilterIcon data-icon="inline-start" />
Доп. фильтры
{activeCount > 0 ? <Badge variant="secondary">{activeCount}</Badge> : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56" />
</DropdownMenu>
{hasFilters ? (
<Button variant="ghost" size="sm" onClick={reset}>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={savePreset}>
<SaveIcon data-icon="inline-start" />
Сохранить пресет
</Button>
{presets.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="sm">Пресеты ({presets.length})</Button>}
/>
<DropdownMenuContent align="end" className="w-56">
{presets.map((p) => (
<DropdownMenuItem
key={p.name}
onClick={() => applyPreset(p)}
className="justify-between"
>
<span className="truncate">{p.name}</span>
<TrashIcon
className="size-4 text-muted-foreground"
onClick={(e) => {
e.stopPropagation()
deletePreset(p.name)
}}
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<Label htmlFor="flt-country" className="text-xs text-muted-foreground">Страна</Label>
<Input
id="flt-country"
placeholder="Любая"
value={filters.country}
onChange={(e) => update('country', e.target.value)}
className="w-36"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="flt-city" className="text-xs text-muted-foreground">Город</Label>
<Input
id="flt-city"
placeholder="Любой"
value={filters.city}
onChange={(e) => update('city', e.target.value)}
className="w-36"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="flt-dc" className="text-xs text-muted-foreground">Дата-центр</Label>
<Input
id="flt-dc"
placeholder="Любой"
value={filters.datacenter}
onChange={(e) => update('datacenter', e.target.value)}
className="w-40"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Окружение</Label>
<SelectField
triggerId="flt-env"
value={filters.environment}
onValueChange={(v) => update('environment', v ?? ALL)}
options={ENV_OPTIONS}
triggerClassName="w-44"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Тариф</Label>
<SelectField
triggerId="flt-tariff"
value={filters.tariffType}
onValueChange={(v) => update('tariffType', v ?? ALL)}
options={TARIFF_OPTIONS}
triggerClassName="w-36"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Мониторинг</Label>
<SelectField
triggerId="flt-mon"
value={filters.monitoring}
onValueChange={(v) => update('monitoring', v ?? ALL)}
options={ON_OFF_OPTIONS}
triggerClassName="w-36"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Бэкап</Label>
<SelectField
triggerId="flt-bkp"
value={filters.backup}
onValueChange={(v) => update('backup', v ?? ALL)}
options={ON_OFF_OPTIONS}
triggerClassName="w-36"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="flt-vcpu" className="text-xs text-muted-foreground">vCPU </Label>
<Input
id="flt-vcpu"
type="number"
min={0}
placeholder="0"
value={filters.minVcpu}
onChange={(e) => update('minVcpu', e.target.value)}
className="w-20"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="flt-ram" className="text-xs text-muted-foreground">RAM </Label>
<Input
id="flt-ram"
type="number"
min={0}
placeholder="0"
value={filters.minRamGb}
onChange={(e) => update('minRamGb', e.target.value)}
className="w-20"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="flt-disk" className="text-xs text-muted-foreground">Disk </Label>
<Input
id="flt-disk"
type="number"
min={0}
placeholder="0"
value={filters.minDiskGb}
onChange={(e) => update('minDiskGb', e.target.value)}
className="w-20"
/>
</div>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.groupByProject}
onCheckedChange={(v) => update('groupByProject', Boolean(v))}
/>
<span className="text-sm">Группировать по проекту</span>
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.tableCompact}
onCheckedChange={(v) => update('tableCompact', Boolean(v))}
/>
<span className="text-sm">Компактная таблица</span>
</label>
</div>
</CardContent>
</Card>
)
}
+8
View File
@@ -72,6 +72,14 @@ export function tariffTypeLabel(type: string): string {
return TARIFF_TYPE_LABELS[type] ?? type
}
const ENVIRONMENT_LABELS: Record<string, string> = {
prod: 'Production', dev: 'Development', staging: 'Staging',
}
export function environmentLabel(env: string): string {
return ENVIRONMENT_LABELS[env] ?? env
}
const CURRENCY_SYMBOL_MAP: Record<string, string> = {
'€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT',
}
+73 -7
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
@@ -20,6 +20,14 @@ import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { SelectField } from '@/components/select-field'
import { Textarea } from '@cfdm/ui/components/textarea'
import {
VpsFilters,
applyVpsFilters,
buildDefaultVpsFilters,
loadFilterPresets,
type VpsFiltersState,
type VpsFilterPreset,
} from '@/components/vps-filters'
import type { Vps } from '@/types/entities'
import { vpsStatusLabel, tariffTypeLabel, formatInBaseCurrency } from '@/lib/format'
@@ -43,6 +51,8 @@ function VpsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
const createMutation = useMutation({
mutationFn: (record: VpsFormValues) => api.create('vps', record as unknown as Vps),
@@ -103,6 +113,47 @@ function VpsPage() {
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const projectNameOptions = useMemo(() => {
const names = new Set<string>()
for (const p of snapshot?.serverProjects ?? []) {
const name = (p as { name?: string }).name?.trim()
if (name) names.add(name)
}
for (const v of snapshot?.vps ?? []) {
const p = (v.project || '').trim()
if (p) names.add(p)
}
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
}, [snapshot])
const filteredVps = useMemo(
() => applyVpsFilters(snapshot?.vps ?? [], filters),
[snapshot?.vps, filters],
)
const tableSections = useMemo(() => {
if (!filters.groupByProject) {
return [{ key: '_flat', label: null as string | null, items: filteredVps }]
}
const map = new Map<string, Vps[]>()
for (const item of filteredVps) {
const key = (item.project || '').trim() || '__none__'
const arr = map.get(key) ?? []
arr.push(item)
map.set(key, arr)
}
const keys = [...map.keys()].sort((a, b) => {
if (a === '__none__') return 1
if (b === '__none__') return -1
return a.localeCompare(b, 'ru')
})
return keys.map((key) => ({
key,
label: key === '__none__' ? 'Без проекта' : key,
items: map.get(key) ?? [],
}))
}, [filteredVps, filters.groupByProject])
const columns: DataTableColumn<Vps>[] = [
{
key: 'ip',
@@ -213,12 +264,27 @@ function VpsPage() {
}
>
{(snap) => (
<DataTableCard
columns={columns}
data={snap.vps}
rowKey={(v) => v.id}
emptyTitle="VPS не найдены"
/>
<div className="flex flex-col gap-4">
<VpsFilters
filters={filters}
onChange={setFilters}
providers={snap.providers}
providerAccounts={snap.providerAccounts}
projectNameOptions={projectNameOptions}
presets={presets}
onPresetsChange={setPresets}
/>
{tableSections.map((section) => (
<DataTableCard
key={section.key}
title={section.label ?? undefined}
columns={columns}
data={section.items}
rowKey={(v) => v.id}
emptyTitle="VPS не найдены"
/>
))}
</div>
)}
</QueryState>
+27
View File
@@ -0,0 +1,27 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@cfdm/ui/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }