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:
@@ -10,6 +10,9 @@
|
|||||||
"cssVariables": true
|
"cssVariables": true
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"hooks": "@/hooks",
|
"hooks": "@/hooks",
|
||||||
|
|||||||
@@ -19,10 +19,13 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-router": "^1.130.2",
|
"@tanstack/react-router": "^1.130.2",
|
||||||
"@tanstack/react-router-devtools": "^1.130.2",
|
"@tanstack/react-router-devtools": "^1.130.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function AutoCompleteInput({
|
|||||||
className={cn('w-full justify-between font-normal', className)}
|
className={cn('w-full justify-between font-normal', className)}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<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')}>
|
<span className={cn('truncate', !value && 'text-muted-foreground')}>
|
||||||
{value ? displayLabel : placeholder}
|
{value ? displayLabel : placeholder}
|
||||||
</span>
|
</span>
|
||||||
@@ -98,7 +98,7 @@ export function AutoCompleteInput({
|
|||||||
</Button>
|
</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>
|
<Command shouldFilter={false} loop>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
@@ -108,7 +108,7 @@ export function AutoCompleteInput({
|
|||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList className="py-1">
|
||||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allowFreeText && query.trim() && !filtered.some(
|
{allowFreeText && query.trim() && !filtered.some(
|
||||||
@@ -117,7 +117,7 @@ export function AutoCompleteInput({
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
value={`__free__:${query.trim()}`}
|
value={`__free__:${query.trim()}`}
|
||||||
onSelect={() => handleSelect(query.trim())}
|
onSelect={() => handleSelect(query.trim())}
|
||||||
className="gap-2"
|
className="gap-2.5"
|
||||||
>
|
>
|
||||||
<SearchIcon className="size-4 opacity-50" />
|
<SearchIcon className="size-4 opacity-50" />
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
@@ -132,10 +132,10 @@ export function AutoCompleteInput({
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
onSelect={() => handleSelect(opt.value)}
|
onSelect={() => handleSelect(opt.value)}
|
||||||
className="gap-2"
|
className="gap-2.5"
|
||||||
>
|
>
|
||||||
{opt.leading ? (
|
{opt.leading ? (
|
||||||
<span className="text-base leading-none">{opt.leading}</span>
|
<span className="size-4 shrink-0 leading-none">{opt.leading}</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="flex-1">{opt.label}</span>
|
<span className="flex-1">{opt.label}</span>
|
||||||
{isSelected ? <CheckIcon className="size-4 opacity-60" /> : null}
|
{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 }
|
||||||
@@ -1,40 +1,27 @@
|
|||||||
import { useMemo } from 'react'
|
import type { Vps } from '@/types/entities'
|
||||||
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'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние фильтров VPS.
|
||||||
|
* Multiselect-поля (status, environment, tariffType, monitoring, backup,
|
||||||
|
* providerId, providerAccountId, project, country, city) — массивы строк.
|
||||||
|
* Пустой массив = «все».
|
||||||
|
*/
|
||||||
export interface VpsFiltersState {
|
export interface VpsFiltersState {
|
||||||
search: string
|
search: string
|
||||||
providerId: string
|
providerId: string[]
|
||||||
providerAccountId: string
|
providerAccountId: string[]
|
||||||
country: string
|
country: string[]
|
||||||
city: string
|
city: string[]
|
||||||
datacenter: string
|
datacenter: string
|
||||||
status: string // 'all' | VpsStatus
|
status: string[]
|
||||||
environment: string // 'all' | env
|
environment: string[]
|
||||||
tariffType: string // 'all' | TariffType
|
tariffType: string[]
|
||||||
monitoring: string // 'all' | 'on' | 'off'
|
monitoring: string[]
|
||||||
backup: string // 'all' | 'on' | 'off'
|
backup: string[]
|
||||||
minVcpu: string
|
minVcpu: number | null
|
||||||
minRamGb: string
|
minRamGb: number | null
|
||||||
minDiskGb: string
|
minDiskGb: number | null
|
||||||
project: string // '' | '__none__' | name
|
project: string[]
|
||||||
groupByProject: boolean
|
groupByProject: boolean
|
||||||
tableCompact: boolean
|
tableCompact: boolean
|
||||||
}
|
}
|
||||||
@@ -42,60 +29,220 @@ export interface VpsFiltersState {
|
|||||||
export function buildDefaultVpsFilters(): VpsFiltersState {
|
export function buildDefaultVpsFilters(): VpsFiltersState {
|
||||||
return {
|
return {
|
||||||
search: '',
|
search: '',
|
||||||
providerId: '',
|
providerId: [],
|
||||||
providerAccountId: '',
|
providerAccountId: [],
|
||||||
country: '',
|
country: [],
|
||||||
city: '',
|
city: [],
|
||||||
datacenter: '',
|
datacenter: '',
|
||||||
status: 'all',
|
status: [],
|
||||||
environment: 'all',
|
environment: [],
|
||||||
tariffType: 'all',
|
tariffType: [],
|
||||||
monitoring: 'all',
|
monitoring: [],
|
||||||
backup: 'all',
|
backup: [],
|
||||||
minVcpu: '',
|
minVcpu: null,
|
||||||
minRamGb: '',
|
minRamGb: null,
|
||||||
minDiskGb: '',
|
minDiskGb: null,
|
||||||
project: '',
|
project: [],
|
||||||
groupByProject: false,
|
groupByProject: false,
|
||||||
tableCompact: 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 = [
|
const matchesAny = <T,>(item: T | undefined, values: T[]): boolean => {
|
||||||
{ value: ALL, label: 'Все статусы' },
|
if (values.length === 0) return true
|
||||||
{ value: 'active', label: vpsStatusLabel('active') },
|
if (item == null) return false
|
||||||
{ value: 'paused', label: vpsStatusLabel('paused') },
|
return values.includes(item)
|
||||||
{ value: 'archived', label: vpsStatusLabel('archived') },
|
}
|
||||||
]
|
|
||||||
|
|
||||||
const ENV_OPTIONS = [
|
const matchesText = (
|
||||||
{ value: ALL, label: 'Все окружения' },
|
item: string | undefined | null,
|
||||||
{ value: 'prod', label: environmentLabel('prod') },
|
values: string[],
|
||||||
{ value: 'dev', label: environmentLabel('dev') },
|
operator: string,
|
||||||
{ value: 'staging', label: environmentLabel('staging') },
|
): 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 = [
|
const matchesNumberGte = (
|
||||||
{ value: ALL, label: 'Все тарифы' },
|
item: number | undefined | null,
|
||||||
{ value: 'monthly', label: tariffTypeLabel('monthly') },
|
values: number[],
|
||||||
{ value: 'daily', label: tariffTypeLabel('daily') },
|
): 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 = [
|
interface ActiveFilter {
|
||||||
{ value: ALL, label: 'Любое' },
|
field: string
|
||||||
{ value: 'on', label: 'Включено' },
|
operator: string
|
||||||
{ value: 'off', label: 'Выключено' },
|
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 {
|
export interface VpsFilterPreset {
|
||||||
name: string
|
name: string
|
||||||
filters: VpsFiltersState
|
filters: VpsFiltersState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRESETS_KEY = 'vps-tracker:vps-filter-presets'
|
||||||
|
|
||||||
export function loadFilterPresets(): VpsFilterPreset[] {
|
export function loadFilterPresets(): VpsFilterPreset[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PRESETS_KEY)
|
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 {
|
try {
|
||||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets))
|
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -115,356 +262,5 @@ function saveFilterPresets(presets: VpsFilterPreset[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyVpsFilters(items: Vps[], filters: VpsFiltersState): Vps[] {
|
// Обратная совместимость — для старого кода, который мог импортировать arrOr
|
||||||
const search = filters.search.toLowerCase()
|
export { arrOr }
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Controller } from 'react-hook-form'
|
||||||
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { format, parseISO, isValid } from 'date-fns'
|
||||||
|
|
||||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
@@ -23,13 +25,22 @@ import { SelectField } from '@/components/select-field'
|
|||||||
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
||||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||||
import {
|
import {
|
||||||
VpsFilters,
|
NumberField,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldInput,
|
||||||
|
} from '@/components/reui/number-field'
|
||||||
|
import {
|
||||||
|
DateSelector,
|
||||||
|
type DateSelectorValue,
|
||||||
|
} from '@/components/reui/date-selector'
|
||||||
|
import {
|
||||||
applyVpsFilters,
|
applyVpsFilters,
|
||||||
buildDefaultVpsFilters,
|
buildDefaultVpsFilters,
|
||||||
loadFilterPresets,
|
|
||||||
type VpsFiltersState,
|
type VpsFiltersState,
|
||||||
type VpsFilterPreset,
|
|
||||||
} from '@/components/vps-filters'
|
} from '@/components/vps-filters'
|
||||||
|
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||||
|
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji, getCountryFlagEmojiByCode } from '@/lib/format'
|
import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji, getCountryFlagEmojiByCode } from '@/lib/format'
|
||||||
@@ -56,7 +67,6 @@ function VpsPage() {
|
|||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
||||||
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
||||||
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
|
||||||
|
|
||||||
const settings = snapshot?.settings[0]
|
const settings = snapshot?.settings[0]
|
||||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||||
@@ -154,6 +164,7 @@ function VpsPage() {
|
|||||||
return {
|
return {
|
||||||
value: name,
|
value: name,
|
||||||
label: name,
|
label: name,
|
||||||
|
code: ref?.code,
|
||||||
leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name),
|
leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -166,9 +177,9 @@ function VpsPage() {
|
|||||||
const c = (v.city || '').trim()
|
const c = (v.city || '').trim()
|
||||||
if (c) names.add(c)
|
if (c) names.add(c)
|
||||||
}
|
}
|
||||||
// Из стандартизированного справочника (фильтр по выбранной стране, если есть)
|
// Из стандартизированного справочника (фильтр по первой выбранной стране, если есть)
|
||||||
const countryCode = filters.country
|
const countryCode = filters.country[0]
|
||||||
? COUNTRY_BY_NAME_RU[filters.country.toLowerCase()]?.code
|
? COUNTRY_BY_NAME_RU[filters.country[0].toLowerCase()]?.code
|
||||||
: undefined
|
: undefined
|
||||||
for (const city of listCities(countryCode)) names.add(city.name)
|
for (const city of listCities(countryCode)) names.add(city.name)
|
||||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({
|
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({
|
||||||
@@ -311,16 +322,15 @@ function VpsPage() {
|
|||||||
>
|
>
|
||||||
{(snap) => (
|
{(snap) => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<VpsFilters
|
<VpsFiltersToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
providers={snap.providers}
|
providers={snap.providers}
|
||||||
providerAccounts={snap.providerAccounts}
|
providerAccounts={snap.providerAccounts}
|
||||||
|
vps={snap.vps}
|
||||||
projectNameOptions={projectNameOptions}
|
projectNameOptions={projectNameOptions}
|
||||||
countryOptions={countryOptions}
|
countryOptions={countryOptions}
|
||||||
cityOptions={cityOptions}
|
cityOptions={cityOptions}
|
||||||
presets={presets}
|
|
||||||
onPresetsChange={setPresets}
|
|
||||||
/>
|
/>
|
||||||
{tableSections.map((section) => (
|
{tableSections.map((section) => (
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
@@ -410,13 +420,67 @@ function VpsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<FormField label="vCPU" htmlFor="vps-vcpu" error={errors.vcpu?.message}>
|
<FormField label="vCPU" htmlFor="vps-vcpu" error={errors.vcpu?.message}>
|
||||||
<Input id="vps-vcpu" type="number" min={0} {...register('vcpu', { valueAsNumber: true })} />
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="vcpu"
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberField
|
||||||
|
id="vps-vcpu"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={Number(field.value ?? 0)}
|
||||||
|
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="RAM (GB)" htmlFor="vps-ram" error={errors.ramGb?.message}>
|
<FormField label="RAM (GB)" htmlFor="vps-ram" error={errors.ramGb?.message}>
|
||||||
<Input id="vps-ram" type="number" min={0} {...register('ramGb', { valueAsNumber: true })} />
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="ramGb"
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberField
|
||||||
|
id="vps-ram"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={Number(field.value ?? 0)}
|
||||||
|
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Disk (GB)" htmlFor="vps-disk" error={errors.diskGb?.message}>
|
<FormField label="Disk (GB)" htmlFor="vps-disk" error={errors.diskGb?.message}>
|
||||||
<Input id="vps-disk" type="number" min={0} {...register('diskGb', { valueAsNumber: true })} />
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="diskGb"
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberField
|
||||||
|
id="vps-disk"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={Number(field.value ?? 0)}
|
||||||
|
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -449,14 +513,73 @@ function VpsPage() {
|
|||||||
<Input id="vps-cur" {...register('currency')} />
|
<Input id="vps-cur" {...register('currency')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Ставка/мес" htmlFor="vps-monthly">
|
<FormField label="Ставка/мес" htmlFor="vps-monthly">
|
||||||
<Input id="vps-monthly" type="number" min={0} step="any" {...register('monthlyRate', { valueAsNumber: true })} />
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="monthlyRate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberField
|
||||||
|
id="vps-monthly"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={Number(field.value ?? 0)}
|
||||||
|
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Ставка/день" htmlFor="vps-daily">
|
<FormField label="Ставка/день" htmlFor="vps-daily">
|
||||||
<Input id="vps-daily" type="number" min={0} step="any" {...register('dailyRate', { valueAsNumber: true })} />
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="dailyRate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberField
|
||||||
|
id="vps-daily"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={Number(field.value ?? 0)}
|
||||||
|
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup>
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<FormField label="Оплачено до" htmlFor="vps-paid">
|
<FormField label="Оплачено до" htmlFor="vps-paid">
|
||||||
<Input id="vps-paid" type="date" {...register('paidUntil')} />
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="paidUntil"
|
||||||
|
render={({ field }) => {
|
||||||
|
const strVal = (field.value as string | undefined) ?? ''
|
||||||
|
const parsed = strVal ? parseISO(strVal) : undefined
|
||||||
|
const dateVal: DateSelectorValue | undefined =
|
||||||
|
parsed && isValid(parsed)
|
||||||
|
? { period: 'day', operator: 'is', startDate: parsed }
|
||||||
|
: undefined
|
||||||
|
return (
|
||||||
|
<DateSelector
|
||||||
|
value={dateVal}
|
||||||
|
onChange={(v) => {
|
||||||
|
const d = v.startDate
|
||||||
|
field.onChange(d ? format(d, 'yyyy-MM-dd') : '')
|
||||||
|
}}
|
||||||
|
allowRange={false}
|
||||||
|
defaultPeriodType="day"
|
||||||
|
showInput
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Заметки" htmlFor="vps-notes">
|
<FormField label="Заметки" htmlFor="vps-notes">
|
||||||
<Textarea id="vps-notes" {...register('notes')} />
|
<Textarea id="vps-notes" {...register('notes')} />
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"cssVariables": true
|
"cssVariables": true
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@cfdm/ui/components",
|
"components": "@cfdm/ui/components",
|
||||||
"utils": "@cfdm/ui/lib/utils",
|
"utils": "@cfdm/ui/lib/utils",
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.0.0"
|
"tailwind-merge": "^3.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Separator } from "@cfdm/ui/components/separator"
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||||
|
vertical:
|
||||||
|
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "button-group-text",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
type Locale,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@cfdm/ui/components/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
locale,
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
locale={locale}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString(locale?.code, { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative rounded-(--cell-radius)",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute inset-0 bg-popover opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"font-medium select-none",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-(--cell-size) select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] text-muted-foreground select-none",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn(
|
||||||
|
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_end
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({ ...props }) => (
|
||||||
|
<CalendarDayButton locale={locale} {...props} />
|
||||||
|
),
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
locale,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: SliderPrimitive.Root.Props) {
|
||||||
|
const _values = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
thumbAlignment="edge"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Indicator
|
||||||
|
data-slot="slider-range"
|
||||||
|
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Control>
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
|||||||
Generated
+48
-3
@@ -86,7 +86,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.6.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.6.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
'@cfdm/shared':
|
'@cfdm/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
@@ -108,9 +108,15 @@ importers:
|
|||||||
'@tanstack/react-router-devtools':
|
'@tanstack/react-router-devtools':
|
||||||
specifier: ^1.130.2
|
specifier: ^1.130.2
|
||||||
version: 1.167.0(@tanstack/[email protected]([email protected]([email protected]))([email protected]))(@tanstack/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
version: 1.167.0(@tanstack/[email protected]([email protected]([email protected]))([email protected]))(@tanstack/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
|
class-variance-authority:
|
||||||
|
specifier: ^0.7.1
|
||||||
|
version: 0.7.1
|
||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0([email protected])
|
version: 0.468.0([email protected])
|
||||||
@@ -120,6 +126,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1(@types/[email protected])([email protected])
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
@@ -196,7 +205,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.6.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.6.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -206,6 +215,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0([email protected])
|
version: 0.468.0([email protected])
|
||||||
@@ -215,6 +227,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1(@types/[email protected])([email protected])
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
@@ -351,6 +366,9 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@date-fns/[email protected]':
|
||||||
|
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||||
|
|
||||||
'@drizzle-team/[email protected]':
|
'@drizzle-team/[email protected]':
|
||||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||||
|
|
||||||
@@ -1848,6 +1866,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2770,6 +2791,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8.0'
|
||||||
|
react: '>=16.8.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3460,7 +3491,7 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.29.7
|
'@babel/helper-string-parser': 7.29.7
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
|
|
||||||
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@base-ui/[email protected](@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
'@base-ui/utils': 0.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
'@base-ui/utils': 0.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -3470,7 +3501,9 @@ snapshots:
|
|||||||
react-dom: 19.2.7([email protected])
|
react-dom: 19.2.7([email protected])
|
||||||
use-sync-external-store: 1.6.0([email protected])
|
use-sync-external-store: 1.6.0([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@date-fns/tz': 1.5.0
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
date-fns: 4.4.0
|
||||||
|
|
||||||
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3483,6 +3516,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
|
'@date-fns/[email protected]': {}
|
||||||
|
|
||||||
'@drizzle-team/[email protected]': {}
|
'@drizzle-team/[email protected]': {}
|
||||||
|
|
||||||
'@esbuild-kit/[email protected]':
|
'@esbuild-kit/[email protected]':
|
||||||
@@ -4740,6 +4775,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
@@ -5607,6 +5644,14 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-json-comments: 2.0.1
|
strip-json-comments: 2.0.1
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@date-fns/tz': 1.5.0
|
||||||
|
date-fns: 4.4.0
|
||||||
|
react: 19.2.7
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user