feat(web): фильтры VPS в стиле reui.io + ReUI-компоненты в форме

- Регистр @reui в components.json (URL https://reui.io/r/{style}/{name}.json)
- Установлены @reui/filters, @reui/autocomplete, @reui/number-field,
  @reui/date-selector и shadcn slider через shadcn CLI
- Поправлены TS-импорты в ReUI-исходниках (type-only) и удалены неиспользуемые
  объявления; в packages/ui добавлены react-day-picker и date-fns для calendar
- Новый VpsFiltersToolbar: тулбар с поиском, кнопкой «Фильтр» (ReUI Filters),
  chips с операторами, Popover «Вид» (группировка/компактность/пресеты)
- vps-filters.tsx переписан под массивы значений (мультивыбор) с поддержкой
  операторов is_any_of / contains / gte; applyVpsFilters принимает как state,
  так и Filter[] (stateToActiveFilters)
- В форме VPS vcpu/ramGb/diskGb/monthlyRate/dailyRate — @reui/number-field
  через Controller, paidUntil — @reui/date-selector (одиночная дата)
- AutoCompleteInput: отступы флага size-4 leading-none, CommandList py-1,
  CommandItem gap-2.5, PopoverContent min-w-[220px]
- build + tsc --noEmit без ошибок

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-26 23:53:16 +07:00
co-authored by Cursor
parent b87d7c6fad
commit 63da57687d
22 changed files with 5122 additions and 454 deletions
@@ -89,7 +89,7 @@ export function AutoCompleteInput({
className={cn('w-full justify-between font-normal', className)}
>
<span className="flex min-w-0 items-center gap-2">
{leading ? <span className="text-base leading-none">{leading}</span> : null}
{leading ? <span className="size-4 shrink-0 leading-none">{leading}</span> : null}
<span className={cn('truncate', !value && 'text-muted-foreground')}>
{value ? displayLabel : placeholder}
</span>
@@ -98,7 +98,7 @@ export function AutoCompleteInput({
</Button>
}
/>
<PopoverContent align="start" className="w-[--anchor-width] p-0">
<PopoverContent align="start" className="w-[--anchor-width] min-w-[220px] p-0">
<Command shouldFilter={false} loop>
<CommandInput
placeholder={searchPlaceholder}
@@ -108,7 +108,7 @@ export function AutoCompleteInput({
autoCorrect="off"
spellCheck={false}
/>
<CommandList>
<CommandList className="py-1">
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{allowFreeText && query.trim() && !filtered.some(
@@ -117,7 +117,7 @@ export function AutoCompleteInput({
<CommandItem
value={`__free__:${query.trim()}`}
onSelect={() => handleSelect(query.trim())}
className="gap-2"
className="gap-2.5"
>
<SearchIcon className="size-4 opacity-50" />
<span className="flex-1 truncate">
@@ -132,10 +132,10 @@ export function AutoCompleteInput({
key={opt.value}
value={opt.value}
onSelect={() => handleSelect(opt.value)}
className="gap-2"
className="gap-2.5"
>
{opt.leading ? (
<span className="text-base leading-none">{opt.leading}</span>
<span className="size-4 shrink-0 leading-none">{opt.leading}</span>
) : null}
<span className="flex-1">{opt.label}</span>
{isSelected ? <CheckIcon className="size-4 opacity-60" /> : null}
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { ScrollArea } from "@cfdm/ui/components/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
import { createContext, type ReactNode, useContext, useId } from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Label } from "@cfdm/ui/components/label"
import { MinusIcon, PlusIcon } from "lucide-react"
const NumberFieldContext = createContext<{
fieldId: string
size: "sm" | "default" | "lg"
} | null>(null)
const numberFieldGroupVariants = cva(
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
{
variants: {
size: {
sm: "h-7 text-sm",
default:
"h-8 text-sm",
lg: "h-9 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldButtonVariants = cva(
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
{
variants: {
size: {
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
default:
"px-2 [&_svg:not([class*='size-'])]:size-4",
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldInputVariants = cva(
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
{
variants: {
size: {
sm: "px-2 py-0.5",
default:
"px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
defaultVariants: {
size: "default",
},
}
)
function NumberField({
id,
className,
size = "default",
...props
}: NumberFieldPrimitive.Root.Props &
VariantProps<typeof numberFieldGroupVariants>) {
const generatedId = useId()
const fieldId = id ?? generatedId
const sizeValue = size ?? "default"
return (
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
<NumberFieldPrimitive.Root
className={cn("flex w-full flex-col items-start gap-2", className)}
data-size={sizeValue}
data-slot="number-field"
id={fieldId}
{...props}
/>
</NumberFieldContext.Provider>
)
}
function NumberFieldGroup({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Group.Props &
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldGroup must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Group
className={cn(numberFieldGroupVariants({ size }), className)}
data-slot="number-field-group"
{...props}
/>
)
}
function NumberFieldDecrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Decrement.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: React.ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldDecrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Decrement
className={cn(
numberFieldButtonVariants({ size }),
"rounded-s-lg border-e-0",
className
)}
data-slot="number-field-decrement"
{...props}
>
{children ?? (
<MinusIcon
/>
)}
</NumberFieldPrimitive.Decrement>
)
}
function NumberFieldIncrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Increment.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldIncrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Increment
className={cn(
numberFieldButtonVariants({ size }),
"rounded-e-lg border-s-0",
className
)}
data-slot="number-field-increment"
{...props}
>
{children ?? (
<PlusIcon
/>
)}
</NumberFieldPrimitive.Increment>
)
}
function NumberFieldInput({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Input.Props &
Partial<VariantProps<typeof numberFieldInputVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldInput must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Input
className={cn(numberFieldInputVariants({ size }), className)}
data-slot="number-field-input"
{...props}
/>
)
}
function NumberFieldScrubArea({
className,
label,
...props
}: NumberFieldPrimitive.ScrubArea.Props & {
label: string
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
)
}
return (
<NumberFieldPrimitive.ScrubArea
className={cn("flex cursor-ew-resize", className)}
data-slot="number-field-scrub-area"
{...props}
>
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
{label}
</Label>
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
<CursorGrowIcon />
</NumberFieldPrimitive.ScrubAreaCursor>
</NumberFieldPrimitive.ScrubArea>
)
}
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="black"
height="14"
stroke="white"
viewBox="0 0 24 14"
width="26"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
</svg>
)
}
export {
NumberField,
NumberFieldScrubArea,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldGroup,
NumberFieldInput,
}
@@ -0,0 +1,432 @@
import { useMemo, useState } from 'react'
import { SearchIcon, SlidersHorizontalIcon, SaveIcon, Trash2Icon, XIcon } 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'
import { Separator } from '@cfdm/ui/components/separator'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@cfdm/ui/components/popover'
import { Slider } from '@cfdm/ui/components/slider'
import {
Filters,
createFilter,
type Filter,
type FilterFieldConfig,
type FilterI18nConfig,
type FilterOption,
} from '@/components/reui/filters'
import {
type VpsFiltersState,
buildDefaultVpsFilters,
stateToActiveFilters,
loadFilterPresets,
saveFilterPresets,
type VpsFilterPreset,
} from '@/components/vps-filters'
import { vpsStatusLabel, tariffTypeLabel, environmentLabel, getCountryFlagEmojiByCode } from '@/lib/format'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
interface VpsFiltersToolbarProps {
filters: VpsFiltersState
onChange: (next: VpsFiltersState) => void
providers: Provider[]
providerAccounts: ProviderAccount[]
vps: Vps[]
countryOptions: { value: string; label: string; code?: string }[]
cityOptions: { value: string; label: string }[]
projectNameOptions: string[]
}
const RU_I18N: FilterI18nConfig = {
addFilter: 'Фильтр',
searchFields: 'Поиск поля…',
noFieldsFound: 'Поля не найдены.',
noResultsFound: 'Нет вариантов',
select: 'Выбрать…',
true: 'Да',
false: 'Нет',
min: 'Мин',
max: 'Макс',
to: 'до',
typeAndPressEnter: 'Введите и нажмите Enter',
selected: 'выбрано',
selectedCount: 'выбрано',
percent: '%',
defaultCurrency: '₽',
defaultColor: '#000000',
addFilterTitle: 'Добавить фильтр',
operators: {
is: '=',
isNot: '≠',
isAnyOf: 'любое из',
isNotAnyOf: 'не любое из',
includesAll: 'включает все',
excludesAll: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
notBetween: 'не между',
contains: 'содержит',
notContains: 'не содержит',
startsWith: 'начинается с',
endsWith: 'заканчивается на',
isExactly: 'точно',
equals: '=',
notEquals: '≠',
greaterThan: '>',
lessThan: '<',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
includesAllOf: 'включает все из',
includesAnyOf: 'включает любое из',
empty: 'пусто',
notEmpty: 'не пусто',
},
placeholders: {
enterField: (t) => `Введите ${t}`,
selectField: 'Выбрать…',
searchField: (n) => `Поиск: ${n.toLowerCase()}`,
enterKey: 'Введите ключ…',
enterValue: 'Введите значение…',
},
helpers: {
formatOperator: (op) => op.replace(/_/g, ' '),
},
validation: {
invalidEmail: 'Некорректный email',
invalidUrl: 'Некорректный URL',
invalidTel: 'Некорректный телефон',
invalid: 'Некорректный формат',
},
}
/** Конвертация VpsFiltersState → Filter[] для ReUI Filters. */
function stateToFilters(state: VpsFiltersState): (Filter<string> | Filter<number>)[] {
const out: (Filter<string> | Filter<number>)[] = []
if (state.providerId.length) out.push(createFilter<string>('providerId', 'is_any_of', state.providerId))
if (state.providerAccountId.length) out.push(createFilter<string>('providerAccountId', 'is_any_of', state.providerAccountId))
if (state.country.length) out.push(createFilter<string>('country', 'is_any_of', state.country))
if (state.city.length) out.push(createFilter<string>('city', 'is_any_of', state.city))
if (state.datacenter) out.push(createFilter<string>('datacenter', 'contains', [state.datacenter]))
if (state.status.length) out.push(createFilter<string>('status', 'is_any_of', state.status))
if (state.environment.length) out.push(createFilter<string>('environment', 'is_any_of', state.environment))
if (state.tariffType.length) out.push(createFilter<string>('tariffType', 'is_any_of', state.tariffType))
if (state.monitoring.length) out.push(createFilter<string>('monitoring', 'is_any_of', state.monitoring))
if (state.backup.length) out.push(createFilter<string>('backup', 'is_any_of', state.backup))
if (state.project.length) out.push(createFilter<string>('project', 'is_any_of', state.project))
if (state.minVcpu != null) out.push(createFilter<number>('minVcpu', 'is', [state.minVcpu]))
if (state.minRamGb != null) out.push(createFilter<number>('minRamGb', 'is', [state.minRamGb]))
if (state.minDiskGb != null) out.push(createFilter<number>('minDiskGb', 'is', [state.minDiskGb]))
return out
}
/** Конвертация Filter[] → VpsFiltersState (merge с default). */
function filtersToState(filters: Filter[], base: VpsFiltersState): VpsFiltersState {
const next = buildDefaultVpsFilters()
next.search = base.search
next.groupByProject = base.groupByProject
next.tableCompact = base.tableCompact
for (const f of filters) {
switch (f.field) {
case 'providerId': next.providerId = f.values as string[]; break
case 'providerAccountId': next.providerAccountId = f.values as string[]; break
case 'country': next.country = f.values as string[]; break
case 'city': next.city = f.values as string[]; break
case 'datacenter': next.datacenter = (f.values[0] as string) ?? ''; break
case 'status': next.status = f.values as string[]; break
case 'environment': next.environment = f.values as string[]; break
case 'tariffType': next.tariffType = f.values as string[]; break
case 'monitoring': next.monitoring = f.values as string[]; break
case 'backup': next.backup = f.values as string[]; break
case 'project': next.project = f.values as string[]; break
case 'minVcpu': next.minVcpu = (f.values[0] as number) ?? null; break
case 'minRamGb': next.minRamGb = (f.values[0] as number) ?? null; break
case 'minDiskGb': next.minDiskGb = (f.values[0] as number) ?? null; break
}
}
return next
}
export function VpsFiltersToolbar({
filters,
onChange,
providers,
providerAccounts,
vps,
countryOptions,
cityOptions,
projectNameOptions,
}: VpsFiltersToolbarProps) {
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
const reuiFilters = useMemo<(Filter<string> | Filter<number>)[]>(() => stateToFilters(filters), [filters])
const fields = useMemo<(FilterFieldConfig<string> | FilterFieldConfig<number>)[]>(() => {
const count = (pred: (v: Vps) => boolean) => vps.filter(pred).length
const providerOpts: FilterOption<string>[] = providers.map((p) => ({
value: p.id,
label: p.name,
metadata: { count: count((v) => v.providerId === p.id) },
}))
const accountOpts: FilterOption<string>[] = providerAccounts.map((a) => ({
value: a.id,
label: a.name,
metadata: { count: count((v) => v.providerAccountId === a.id) },
}))
const countryOpts: FilterOption<string>[] = countryOptions.map((c) => ({
value: c.value,
label: c.label,
icon: c.code ? <span className="size-4 leading-none">{getCountryFlagEmojiByCode(c.code)}</span> : undefined,
metadata: { count: count((v) => (v.country ?? '').trim() === c.value) },
}))
const cityOpts: FilterOption<string>[] = cityOptions.map((c) => ({
value: c.value,
label: c.label,
metadata: { count: count((v) => (v.city ?? '').trim() === c.value) },
}))
const projectOpts: FilterOption<string>[] = [
{ value: '__none__', label: 'Без проекта', metadata: { count: count((v) => !(v.project ?? '').trim()) } },
...projectNameOptions.map((p) => ({
value: p,
label: p,
metadata: { count: count((v) => (v.project ?? '').trim() === p) },
})),
]
const statusOpts: FilterOption<string>[] = [
{ value: 'active', label: vpsStatusLabel('active') },
{ value: 'paused', label: vpsStatusLabel('paused') },
{ value: 'archived', label: vpsStatusLabel('archived') },
]
const envOpts: FilterOption<string>[] = [
{ value: 'prod', label: environmentLabel('prod') },
{ value: 'dev', label: environmentLabel('dev') },
{ value: 'staging', label: environmentLabel('staging') },
]
const tariffOpts: FilterOption<string>[] = [
{ value: 'monthly', label: tariffTypeLabel('monthly') },
{ value: 'daily', label: tariffTypeLabel('daily') },
]
const onOffOpts: FilterOption<string>[] = [
{ value: 'on', label: 'Включено' },
{ value: 'off', label: 'Выключено' },
]
return [
{ key: 'providerId', label: 'Хостер', type: 'multiselect' as const, options: providerOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'providerAccountId', label: 'Аккаунт', type: 'multiselect' as const, options: accountOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'country', label: 'Страна', type: 'multiselect' as const, options: countryOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'city', label: 'Город', type: 'multiselect' as const, options: cityOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'datacenter', label: 'Дата-центр', type: 'text' as const, placeholder: 'Напр. Frankfurt', defaultOperator: 'contains' },
{ key: 'status', label: 'Статус', type: 'multiselect' as const, options: statusOpts, defaultOperator: 'is_any_of' },
{ key: 'environment', label: 'Окружение', type: 'multiselect' as const, options: envOpts, defaultOperator: 'is_any_of' },
{ key: 'tariffType', label: 'Тариф', type: 'multiselect' as const, options: tariffOpts, defaultOperator: 'is_any_of' },
{ key: 'monitoring', label: 'Мониторинг', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
{ key: 'backup', label: 'Бэкап', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
{ key: 'project', label: 'Проект', type: 'multiselect' as const, options: projectOpts, searchable: true, defaultOperator: 'is_any_of' },
{
key: 'minVcpu',
label: 'vCPU ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
<div className="flex items-center gap-2 px-2 py-1 w-44">
<Slider
min={0}
max={32}
step={1}
value={values[0] ?? 0}
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
className="flex-1"
/>
<span className="w-8 text-sm tabular-nums text-end">{values[0] ?? 0}</span>
</div>
),
},
{
key: 'minRamGb',
label: 'RAM ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
<div className="flex items-center gap-2 px-2 py-1 w-44">
<Slider
min={0}
max={256}
step={1}
value={values[0] ?? 0}
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
className="flex-1"
/>
<span className="w-10 text-sm tabular-nums text-end">{values[0] ?? 0} GB</span>
</div>
),
},
{
key: 'minDiskGb',
label: 'Disk ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
<div className="flex items-center gap-2 px-2 py-1 w-44">
<Slider
min={0}
max={2000}
step={10}
value={values[0] ?? 0}
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
className="flex-1"
/>
<span className="w-14 text-sm tabular-nums text-end">{values[0] ?? 0} GB</span>
</div>
),
},
]
}, [providers, providerAccounts, vps, countryOptions, cityOptions, projectNameOptions])
const handleFiltersChange = (next: Filter[]) => {
onChange(filtersToState(next, filters))
}
const hasActive = reuiFilters.length > 0 || filters.search || 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 }]
setPresets(next)
saveFilterPresets(next)
}
const applyPreset = (preset: VpsFilterPreset) => {
onChange({ ...buildDefaultVpsFilters(), ...preset.filters })
}
const deletePreset = (name: string) => {
const next = presets.filter((p) => p.name !== name)
setPresets(next)
saveFilterPresets(next)
}
const reset = () => onChange(buildDefaultVpsFilters())
return (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<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"
/>
</div>
<Filters
filters={reuiFilters as unknown as Filter[]}
fields={fields as unknown as FilterFieldConfig[]}
onChange={handleFiltersChange}
i18n={RU_I18N}
size="sm"
allowMultiple={false}
/>
<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" />
Сохранить
</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>
{hasActive ? (
<Button variant="ghost" size="sm" onClick={reset}>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
</div>
)
}
// Обратная совместимость — не используется напрямую, но экспортируем
export { stateToActiveFilters }
+220 -424
View File
@@ -1,40 +1,27 @@
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 { AutoCompleteInput, type AutoCompleteOption } from '@/components/auto-complete-input'
import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
import type { Vps } from '@/types/entities'
/**
* Состояние фильтров VPS.
* Multiselect-поля (status, environment, tariffType, monitoring, backup,
* providerId, providerAccountId, project, country, city) — массивы строк.
* Пустой массив = «все».
*/
export interface VpsFiltersState {
search: string
providerId: string
providerAccountId: string
country: string
city: 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
status: string[]
environment: string[]
tariffType: string[]
monitoring: string[]
backup: string[]
minVcpu: number | null
minRamGb: number | null
minDiskGb: number | null
project: string[]
groupByProject: boolean
tableCompact: boolean
}
@@ -42,60 +29,220 @@ export interface VpsFiltersState {
export function buildDefaultVpsFilters(): VpsFiltersState {
return {
search: '',
providerId: '',
providerAccountId: '',
country: '',
city: '',
providerId: [],
providerAccountId: [],
country: [],
city: [],
datacenter: '',
status: 'all',
environment: 'all',
tariffType: 'all',
monitoring: 'all',
backup: 'all',
minVcpu: '',
minRamGb: '',
minDiskGb: '',
project: '',
status: [],
environment: [],
tariffType: [],
monitoring: [],
backup: [],
minVcpu: null,
minRamGb: null,
minDiskGb: null,
project: [],
groupByProject: false,
tableCompact: false,
}
}
const ALL = 'all'
const arrOr = <T,>(v: T[] | T | undefined | null): T[] =>
Array.isArray(v) ? v : v == null || v === '' ? [] : [v]
const STATUS_OPTIONS = [
{ value: ALL, label: 'Все статусы' },
{ value: 'active', label: vpsStatusLabel('active') },
{ value: 'paused', label: vpsStatusLabel('paused') },
{ value: 'archived', label: vpsStatusLabel('archived') },
]
const matchesAny = <T,>(item: T | undefined, values: T[]): boolean => {
if (values.length === 0) return true
if (item == null) return false
return values.includes(item)
}
const ENV_OPTIONS = [
{ value: ALL, label: 'Все окружения' },
{ value: 'prod', label: environmentLabel('prod') },
{ value: 'dev', label: environmentLabel('dev') },
{ value: 'staging', label: environmentLabel('staging') },
]
const matchesText = (
item: string | undefined | null,
values: string[],
operator: string,
): boolean => {
if (values.length === 0) return true
const v = (item ?? '').toLowerCase()
// Для text-фильтра values — массив из одного элемента
const q = (values[0] ?? '').toLowerCase()
if (!q) return true
switch (operator) {
case 'not_contains':
return !v.includes(q)
case 'starts_with':
return v.startsWith(q)
case 'ends_with':
return v.endsWith(q)
case 'is':
return v === q
default:
return v.includes(q)
}
}
const TARIFF_OPTIONS = [
{ value: ALL, label: 'Все тарифы' },
{ value: 'monthly', label: tariffTypeLabel('monthly') },
{ value: 'daily', label: tariffTypeLabel('daily') },
]
const matchesNumberGte = (
item: number | undefined | null,
values: number[],
): boolean => {
if (values.length === 0) return true
const threshold = values[0]
if (threshold == null) return true
return Number(item ?? 0) >= threshold
}
const ON_OFF_OPTIONS = [
{ value: ALL, label: 'Любое' },
{ value: 'on', label: 'Включено' },
{ value: 'off', label: 'Выключено' },
]
interface ActiveFilter {
field: string
operator: string
values: unknown[]
}
const PRESETS_KEY = 'vps-tracker:vps-filter-presets'
/**
* Применяет фильтры к списку VPS.
* Принимает активные фильтры в формате ReUI Filters (Filter[]) —
* массив объектов { field, operator, values }.
*/
export function applyVpsFilters(
items: Vps[],
filters: VpsFiltersState | ActiveFilter[],
): Vps[] {
// Если передан state — конвертируем в active filters
const activeFilters: ActiveFilter[] = Array.isArray(filters)
? filters
: stateToActiveFilters(filters)
const search = activeFilters.find((f) => f.field === 'search')?.values?.[0] as string ?? ''
const searchLower = search.toLowerCase()
return items.filter((item) => {
if (searchLower) {
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
const haystack = [
item.ip, item.dns, item.ipv6, extraIps, item.project, item.purpose, item.os,
].map((s) => (s ?? '').toLowerCase()).join(' ')
if (!haystack.includes(searchLower)) return false
}
for (const f of activeFilters) {
if (f.field === 'search') continue
switch (f.field) {
case 'providerId':
if (!matchesAny(item.providerId, f.values as string[])) return false
break
case 'providerAccountId':
if (!matchesAny(item.providerAccountId, f.values as string[])) return false
break
case 'country':
if (!matchesAny((item.country ?? '').trim() || undefined, f.values as string[])) return false
break
case 'city':
if (!matchesAny((item.city ?? '').trim() || undefined, f.values as string[])) return false
break
case 'datacenter':
if (!matchesText(item.datacenter, f.values as string[], f.operator)) return false
break
case 'status':
if (!matchesAny(item.status, f.values as string[])) return false
break
case 'environment':
if (!matchesAny(item.environment, f.values as string[])) return false
break
case 'tariffType':
if (!matchesAny(item.tariffType, f.values as string[])) return false
break
case 'monitoring': {
if (f.values.length === 0) break
const on = (f.values as string[]).includes('on')
const off = (f.values as string[]).includes('off')
const itemOn = Boolean(item.monitoringEnabled)
if (on && itemOn) break
if (off && !itemOn) break
return false
}
case 'backup': {
if (f.values.length === 0) break
const on = (f.values as string[]).includes('on')
const off = (f.values as string[]).includes('off')
const itemOn = Boolean(item.backupEnabled)
if (on && itemOn) break
if (off && !itemOn) break
return false
}
case 'project': {
if (f.values.length === 0) break
const proj = (item.project ?? '').trim()
const wants = f.values as string[]
const wantsNone = wants.includes('__none__')
const wantsNamed = wants.filter((v) => v !== '__none__')
let ok = false
if (wantsNone && !proj) ok = true
if (wantsNamed.length && wantsNamed.includes(proj)) ok = true
if (!ok) return false
break
}
case 'minVcpu':
if (!matchesNumberGte(item.vcpu, f.values as number[])) return false
break
case 'minRamGb':
if (!matchesNumberGte(item.ramGb, f.values as number[])) return false
break
case 'minDiskGb':
if (!matchesNumberGte(item.diskGb, f.values as number[])) return false
break
}
}
return true
})
}
/** Конвертация VpsFiltersState → ActiveFilter[] для applyVpsFilters. */
export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] {
const out: ActiveFilter[] = []
if (state.search) out.push({ field: 'search', operator: 'contains', values: [state.search] })
if (state.providerId.length) out.push({ field: 'providerId', operator: 'is_any_of', values: state.providerId })
if (state.providerAccountId.length) out.push({ field: 'providerAccountId', operator: 'is_any_of', values: state.providerAccountId })
if (state.country.length) out.push({ field: 'country', operator: 'is_any_of', values: state.country })
if (state.city.length) out.push({ field: 'city', operator: 'is_any_of', values: state.city })
if (state.datacenter) out.push({ field: 'datacenter', operator: 'contains', values: [state.datacenter] })
if (state.status.length) out.push({ field: 'status', operator: 'is_any_of', values: state.status })
if (state.environment.length) out.push({ field: 'environment', operator: 'is_any_of', values: state.environment })
if (state.tariffType.length) out.push({ field: 'tariffType', operator: 'is_any_of', values: state.tariffType })
if (state.monitoring.length) out.push({ field: 'monitoring', operator: 'is_any_of', values: state.monitoring })
if (state.backup.length) out.push({ field: 'backup', operator: 'is_any_of', values: state.backup })
if (state.project.length) out.push({ field: 'project', operator: 'is_any_of', values: state.project })
if (state.minVcpu != null) out.push({ field: 'minVcpu', operator: 'gte', values: [state.minVcpu] })
if (state.minRamGb != null) out.push({ field: 'minRamGb', operator: 'gte', values: [state.minRamGb] })
if (state.minDiskGb != null) out.push({ field: 'minDiskGb', operator: 'gte', values: [state.minDiskGb] })
return out
}
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.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.minVcpu != null) n++
if (filters.minRamGb != null) n++
if (filters.minDiskGb != null) n++
return n
}
export interface VpsFilterPreset {
name: string
filters: VpsFiltersState
}
const PRESETS_KEY = 'vps-tracker:vps-filter-presets'
export function loadFilterPresets(): VpsFilterPreset[] {
try {
const raw = localStorage.getItem(PRESETS_KEY)
@@ -107,7 +254,7 @@ export function loadFilterPresets(): VpsFilterPreset[] {
}
}
function saveFilterPresets(presets: VpsFilterPreset[]): void {
export function saveFilterPresets(presets: VpsFilterPreset[]): void {
try {
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets))
} catch {
@@ -115,356 +262,5 @@ function saveFilterPresets(presets: VpsFilterPreset[]): void {
}
}
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[]
countryOptions: AutoCompleteOption[]
cityOptions: AutoCompleteOption[]
presets: VpsFilterPreset[]
onPresetsChange: (presets: VpsFilterPreset[]) => void
}
export function VpsFilters({
filters,
onChange,
providers,
providerAccounts,
projectNameOptions,
countryOptions,
cityOptions,
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>
<AutoCompleteInput
id="flt-country"
placeholder="Любая"
value={filters.country}
onChange={(v) => update('country', v)}
options={countryOptions}
searchPlaceholder="Поиск страны…"
emptyText="Нет вариантов"
className="w-36"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="flt-city" className="text-xs text-muted-foreground">Город</Label>
<AutoCompleteInput
id="flt-city"
placeholder="Любой"
value={filters.city}
onChange={(v) => update('city', v)}
options={cityOptions}
searchPlaceholder="Поиск города…"
emptyText="Нет вариантов"
showLeadingInInput={false}
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>
)
}
// Обратная совместимость — для старого кода, который мог импортировать arrOr
export { arrOr }