diff --git a/apps/web/components.json b/apps/web/components.json index e22ea1a..5afe9ff 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -10,6 +10,9 @@ "cssVariables": true }, "iconLibrary": "lucide", + "registries": { + "@reui": "https://reui.io/r/{style}/{name}.json" + }, "aliases": { "components": "@/components", "hooks": "@/hooks", diff --git a/apps/web/package.json b/apps/web/package.json index 9ff086d..96c31af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,10 +19,13 @@ "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2", + "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", + "date-fns": "^4.4.0", "lucide-react": "^0.468.0", "next-themes": "^0.4.6", "react": "^19.2.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", "react-hook-form": "^7.60.0", "recharts": "3.8.0", diff --git a/apps/web/src/components/auto-complete-input.tsx b/apps/web/src/components/auto-complete-input.tsx index c8e7d97..24c86d8 100644 --- a/apps/web/src/components/auto-complete-input.tsx +++ b/apps/web/src/components/auto-complete-input.tsx @@ -89,7 +89,7 @@ export function AutoCompleteInput({ className={cn('w-full justify-between font-normal', className)} > - {leading ? {leading} : null} + {leading ? {leading} : null} {value ? displayLabel : placeholder} @@ -98,7 +98,7 @@ export function AutoCompleteInput({ } /> - + - + {emptyText} {allowFreeText && query.trim() && !filtered.some( @@ -117,7 +117,7 @@ export function AutoCompleteInput({ handleSelect(query.trim())} - className="gap-2" + className="gap-2.5" > @@ -132,10 +132,10 @@ export function AutoCompleteInput({ key={opt.value} value={opt.value} onSelect={() => handleSelect(opt.value)} - className="gap-2" + className="gap-2.5" > {opt.leading ? ( - {opt.leading} + {opt.leading} ) : null} {opt.label} {isSelected ? : null} diff --git a/apps/web/src/components/reui/autocomplete.tsx b/apps/web/src/components/reui/autocomplete.tsx new file mode 100644 index 0000000..76710af --- /dev/null +++ b/apps/web/src/components/reui/autocomplete.tsx @@ -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 ( + + ) +} + +function AutocompleteInput({ + className, + size = "default", + showClear = false, + showTrigger = false, + ...props +}: Omit & + VariantProps & { + showClear?: boolean + showTrigger?: boolean + }) { + return ( +
+ + {showTrigger && } + {showClear && } +
+ ) +} + +function AutocompleteStatus({ + className, + ...props +}: AutocompletePrimitive.Status.Props) { + return ( + + ) +} + +function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) { + return ( + + ) +} + +function AutocompleteBackdrop({ + ...props +}: AutocompletePrimitive.Backdrop.Props) { + return ( + + ) +} + +function AutocompletePositioner({ + className, + ...props +}: AutocompletePrimitive.Positioner.Props) { + return ( + + ) +} + +function AutocompleteList({ + className, + scrollAreaClassName, + ...props +}: AutocompletePrimitive.List.Props & { + scrollAreaClassName?: string + scrollFade?: boolean + scrollbarGutter?: boolean +}) { + return ( + + + + ) +} + +function AutocompleteCollection({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteRow({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +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 ( + + {showBackdrop && } + +
+ + {children} + +
+
+
+ ) +} + +function AutocompleteGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteGroupLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteClear({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteArrow({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Autocomplete, + AutocompleteValue, + AutocompleteTrigger, + AutocompleteInput, + AutocompleteStatus, + AutocompletePortal, + AutocompleteBackdrop, + AutocompletePositioner, + AutocompleteContent, + AutocompleteList, + AutocompleteCollection, + AutocompleteRow, + AutocompleteItem, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteEmpty, + AutocompleteClear, + AutocompleteArrow, + AutocompleteSeparator, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/date-selector.tsx b/apps/web/src/components/reui/date-selector.tsx new file mode 100644 index 0000000..535643b --- /dev/null +++ b/apps/web/src/components/reui/date-selector.tsx @@ -0,0 +1,1330 @@ +import { + type ChangeEvent, + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react" +import { + addMonths, + format, + isBefore, + isSameMonth, + parse, + subMonths, +} from "date-fns" +import { DayButton } from "react-day-picker" +import type { DateRange } from "react-day-picker" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { Calendar, CalendarDayButton } from "@cfdm/ui/components/calendar" +import { Input } from "@cfdm/ui/components/input" +import { ScrollArea } from "@cfdm/ui/components/scroll-area" +import { Tabs, TabsList, TabsTrigger } from "@cfdm/ui/components/tabs" +import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react" + +export interface DateSelectorI18nConfig { + // Labels + selectDate: string + apply: string + cancel: string + clear: string + today: string + // Filter types + filterTypes: { + is: string + before: string + after: string + between: string + } + // Period types + periodTypes: { + day: string + month: string + quarter: string + halfYear: string + year: string + } + // Months + months: string[] + monthsShort: string[] + // Quarters + quarters: string[] + // Half years + halfYears: string[] + // Weekdays + weekdays: string[] + weekdaysShort: string[] + // Placeholders + placeholder: string + rangePlaceholder: string +} + +export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = { + selectDate: "Select date", + apply: "Apply", + cancel: "Cancel", + clear: "Clear", + today: "Today", + filterTypes: { + is: "is", + before: "before", + after: "after", + between: "between", + }, + periodTypes: { + day: "Day", + month: "Month", + quarter: "Quarter", + halfYear: "Half-year", + year: "Year", + }, + months: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + monthsShort: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + quarters: ["Q1", "Q2", "Q3", "Q4"], + halfYears: ["H1", "H2"], + weekdays: [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + placeholder: "Select date...", + rangePlaceholder: "Select date range...", +} + +export type DateSelectorPeriodType = + | "day" + | "month" + | "quarter" + | "half-year" + | "year" +export type DateSelectorFilterType = "is" | "before" | "after" | "between" + +export interface DateSelectorValue { + period: DateSelectorPeriodType + operator: DateSelectorFilterType + startDate?: Date + endDate?: Date + year?: number + month?: number + quarter?: number + halfYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } +} + +export interface DateSelectorContextValue { + i18n: DateSelectorI18nConfig + variant: "outline" | "default" + size: "sm" | "default" | "lg" +} + +const DateSelectorContext = createContext({ + i18n: DEFAULT_DATE_SELECTOR_I18N, + variant: "outline", + size: "default", +}) + +export const useDateSelectorContext = () => useContext(DateSelectorContext) + +export function formatDateValue( + value: DateSelectorValue, + i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N, + dayDateFormat: string = "MM/dd/yyyy" +): string { + const { + period, + startDate, + endDate, + year, + month, + quarter, + halfYear, + rangeStart, + rangeEnd, + } = value + + if (period === "day") { + if (startDate && endDate) { + return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}` + } + if (startDate) { + return format(startDate, dayDateFormat) + } + return "" + } + + if (period === "month") { + if (rangeStart && rangeEnd) { + return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && month !== undefined) { + return `${i18n.monthsShort[month]} ${year}` + } + return "" + } + + if (period === "quarter") { + if (rangeStart && rangeEnd) { + return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && quarter !== undefined) { + return `${i18n.quarters[quarter]} ${year}` + } + return "" + } + + if (period === "half-year") { + if (rangeStart && rangeEnd) { + return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && halfYear !== undefined) { + return `${i18n.halfYears[halfYear]} ${year}` + } + return "" + } + + if (period === "year") { + if (rangeStart && rangeEnd) { + return `${rangeStart.year} - ${rangeEnd.year}` + } + if (year !== undefined) { + return `${year}` + } + return "" + } + + return "" +} + +interface UseDateSelectorOptions { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + allowRange?: boolean + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + periodTypes?: DateSelectorPeriodType[] +} + +export function useDateSelector({ + value, + onChange, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + allowRange = true, + yearRange = 11, + baseYear, + minYear, + maxYear, + periodTypes, +}: UseDateSelectorOptions) { + const currentYear = baseYear ?? new Date().getFullYear() + + const validDefaultPeriodType = useMemo(() => { + if (!periodTypes || periodTypes.length === 0) return defaultPeriodType + if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType + return periodTypes[0] + }, [periodTypes, defaultPeriodType]) + + // Use presetMode if provided, otherwise use value or default + const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType + + const [periodType, setPeriodType] = useState( + value?.period || validDefaultPeriodType + ) + const [filterType, setFilterType] = + useState(effectiveFilterType) + const [selectedDate, setSelectedDate] = useState( + value?.startDate + ) + const [selectedEndDate, setSelectedEndDate] = useState( + value?.endDate + ) + const [calendarMonth, setCalendarMonth] = useState( + value?.startDate || new Date() + ) + const [selectedYear, setSelectedYear] = useState( + value?.year + ) + const [selectedMonth, setSelectedMonth] = useState( + value?.month + ) + const [selectedQuarter, setSelectedQuarter] = useState( + value?.quarter + ) + const [selectedHalfYear, setSelectedHalfYear] = useState( + value?.halfYear + ) + const [rangeStart, setRangeStart] = useState< + { year: number; value: number } | undefined + >(value?.rangeStart) + const [rangeEnd, setRangeEnd] = useState< + { year: number; value: number } | undefined + >(value?.rangeEnd) + const [hoverDate, setHoverDate] = useState() + + const years = useMemo(() => { + if (minYear !== undefined && maxYear !== undefined) { + return Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => minYear + i + ) + } + return Array.from( + { length: yearRange }, + (_, i) => currentYear - Math.floor(yearRange / 2) + i + ) + }, [currentYear, yearRange, minYear, maxYear]) + + const currentValue = useMemo( + () => ({ + period: periodType, + operator: presetMode ?? filterType, + startDate: selectedDate, + endDate: selectedEndDate, + year: selectedYear, + month: selectedMonth, + quarter: selectedQuarter, + halfYear: selectedHalfYear, + rangeStart, + rangeEnd, + }), + [ + periodType, + presetMode, + filterType, + selectedDate, + selectedEndDate, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + ] + ) + + const clearSelection = useCallback(() => { + setSelectedDate(undefined) + setSelectedEndDate(undefined) + setSelectedYear(undefined) + setSelectedMonth(undefined) + setSelectedQuarter(undefined) + setSelectedHalfYear(undefined) + setRangeStart(undefined) + setRangeEnd(undefined) + }, []) + + const handleDayClick = useCallback( + (day: Date) => { + if (filterType === "between" && allowRange) { + if (!selectedDate || (selectedDate && selectedEndDate)) { + setSelectedDate(day) + setSelectedEndDate(undefined) + } else { + if (isBefore(day, selectedDate)) { + setSelectedEndDate(selectedDate) + setSelectedDate(day) + } else { + setSelectedEndDate(day) + } + } + } else { + setSelectedDate(day) + setSelectedEndDate(undefined) + } + }, + [filterType, allowRange, selectedDate, selectedEndDate] + ) + + const handlePeriodSelect = useCallback( + (year: number, value: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value }) + setRangeEnd(undefined) + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + } else { + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = year * 100 + value + if (endKey < startKey) { + setRangeEnd(rangeStart) + setRangeStart({ year, value }) + } else { + setRangeEnd({ year, value }) + } + } + } else { + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd, periodType] + ) + + const handleYearSelect = useCallback( + (year: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value: 0 }) + setRangeEnd(undefined) + setSelectedYear(year) + } else { + if (year < rangeStart.year) { + setRangeEnd(rangeStart) + setRangeStart({ year, value: 0 }) + } else { + setRangeEnd({ year, value: 0 }) + } + } + } else { + setSelectedYear(year) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd] + ) + + const handlePeriodTypeChange = useCallback( + (type: DateSelectorPeriodType) => { + setPeriodType(type) + clearSelection() + }, + [clearSelection] + ) + + const handleFilterTypeChange = useCallback( + (type: DateSelectorFilterType) => { + // Don't allow changes if presetMode is set + if (presetMode !== undefined) return + setFilterType(type) + clearSelection() + }, + [clearSelection, presetMode] + ) + + const isInRange = useCallback( + (year: number, value: number) => { + if (!rangeStart || !rangeEnd) return false + const key = year * 100 + value + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = rangeEnd.year * 100 + rangeEnd.value + return key >= startKey && key <= endKey + }, + [rangeStart, rangeEnd] + ) + + const isYearInRange = useCallback( + (year: number) => { + if (!rangeStart || !rangeEnd) return false + return year >= rangeStart.year && year <= rangeEnd.year + }, + [rangeStart, rangeEnd] + ) + + useEffect(() => { + if (value) { + setPeriodType(value.period || validDefaultPeriodType) + // Use presetMode if provided, otherwise use value's operator or default + const newFilterType = presetMode ?? value.operator ?? defaultFilterType + setFilterType(newFilterType) + setSelectedDate(value.startDate) + setSelectedEndDate(value.endDate) + setSelectedYear(value.year) + setSelectedMonth(value.month) + setSelectedQuarter(value.quarter) + setSelectedHalfYear(value.halfYear) + setRangeStart(value.rangeStart) + setRangeEnd(value.rangeEnd) + } + }, [value, validDefaultPeriodType, defaultFilterType, presetMode]) + + // Sync filterType when presetMode changes + useEffect(() => { + if (presetMode !== undefined) { + setFilterType(presetMode) + } + }, [presetMode]) + + useEffect(() => { + onChange?.(currentValue) + }, [currentValue, onChange]) + + return { + // State + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + allowRange, + + // Setters + setPeriodType: handlePeriodTypeChange, + setFilterType: handleFilterTypeChange, + setSelectedDate, + setSelectedEndDate, + setCalendarMonth, + setHoverDate, + + // Actions + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } +} + +interface DateSelectorFilterToggleProps { + value: DateSelectorFilterType + onChange: (value: DateSelectorFilterType) => void + showBetween?: boolean + showIs?: boolean + presetMode?: DateSelectorFilterType + className?: string +} + +function DateSelectorFilterToggle({ + value, + onChange, + showBetween = true, + showIs = true, + presetMode, + className, +}: DateSelectorFilterToggleProps) { + const { i18n } = useDateSelectorContext() + const isDisabled = presetMode !== undefined + + return ( + { + if (!isDisabled && newValue) { + onChange(newValue as DateSelectorFilterType) + } + }} + className={className} + > + + {showIs && ( + + {i18n.filterTypes.is} + + )} + + {i18n.filterTypes.before} + + + {i18n.filterTypes.after} + + {showBetween && ( + + {i18n.filterTypes.between} + + )} + + + ) +} + +interface DateSelectorDateSelectorPeriodTabsProps { + value: DateSelectorPeriodType + onChange: (value: DateSelectorPeriodType) => void + periodTypes?: DateSelectorPeriodType[] + className?: string + calendarMonth?: Date + onMonthChange?: (date: Date) => void + showNavigationButtons?: boolean +} + +function DateSelectorPeriodTabs({ + value, + onChange, + periodTypes, + className, + calendarMonth, + onMonthChange, + showNavigationButtons = false, +}: DateSelectorDateSelectorPeriodTabsProps) { + const { i18n } = useDateSelectorContext() + + const tabs: { value: DateSelectorPeriodType; label: string }[] = [ + { value: "day", label: i18n.periodTypes.day }, + { value: "month", label: i18n.periodTypes.month }, + { value: "quarter", label: i18n.periodTypes.quarter }, + { value: "half-year", label: i18n.periodTypes.halfYear }, + { value: "year", label: i18n.periodTypes.year }, + ] + + const filteredTabs = periodTypes + ? tabs.filter((tab) => periodTypes.includes(tab.value)) + : tabs + + return ( +
+ { + if (newValue) { + onChange(newValue as DateSelectorPeriodType) + } + }} + > + + {filteredTabs.map((tab) => ( + + {tab.label} + + ))} + + + {showNavigationButtons && + value === "day" && + calendarMonth && + onMonthChange && ( +
+ {(() => { + const today = new Date() + const isCurrentMonth = isSameMonth(calendarMonth, today) + + // Only show today button if not on current month + if (isCurrentMonth) { + return null + } + + // Determine direction based on whether calendarMonth is in future or past + const isFuture = calendarMonth > today + + return ( + + ) + })()} + + +
+ )} +
+ ) +} + +interface DateSelectorDayPickerProps { + currentMonth: Date + selectedDate?: Date + selectedEndDate?: Date + onDayClick: (day: Date) => void + isRange: boolean + onDayHover?: (day: Date | undefined) => void + hoverDate?: Date + showTwoMonths?: boolean + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 + className?: string +} + +function DateSelectorDayPicker({ + currentMonth, + selectedDate, + selectedEndDate, + onDayClick, + isRange, + onDayHover, + hoverDate, + showTwoMonths = true, + weekStartsOn, + className, +}: DateSelectorDayPickerProps) { + const { i18n } = useDateSelectorContext() + const isMobile = useIsMobile() + + // Convert to react-day-picker format + const selected: Date | DateRange | undefined = isRange + ? selectedDate && selectedEndDate + ? { from: selectedDate, to: selectedEndDate } + : selectedDate + ? { from: selectedDate, to: hoverDate || selectedDate } + : undefined + : selectedDate + + const handleSelect = (date: Date | DateRange | undefined) => { + if (!date) { + return + } + + if (isRange && "from" in date) { + // For range mode + if (date.from && !date.to) { + // First click - set start date + onDayClick(date.from) + } else if (date.from && date.to) { + // Range selected - set end date + onDayClick(date.to) + } + } else if (!isRange && date instanceof Date) { + onDayClick(date) + } + } + + // Create custom DayButton component with hover support + const CustomDayButton = useCallback( + (props: ComponentProps) => { + return ( + { + if (isRange && onDayHover && props.day) { + onDayHover(props.day.date) + } + }} + onMouseLeave={() => { + if (isRange && onDayHover) { + onDayHover(undefined) + } + }} + /> + ) + }, + [isRange, onDayHover] + ) + + // Create custom formatters for i18n + const formatters = { + formatWeekdayName: (date: Date) => { + const dayIndex = date.getDay() + return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] + }, + formatMonthCaption: (date: Date) => { + const monthIndex = date.getMonth() + const year = date.getFullYear() + return `${i18n.months[monthIndex]} ${year}` + }, + } + + return ( +
+ {isRange ? ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + ) : ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + )} +
+ ) +} + +interface DateSelectorDateSelectorPeriodGridProps { + years: number[] + items: string[] + selectedYear?: number + selectedValue?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isInRange: (year: number, value: number) => boolean + onSelect: (year: number, value: number) => void + columns: number + className?: string +} + +function DateSelectorPeriodGrid({ + years, + items, + selectedYear, + selectedValue, + rangeStart, + rangeEnd, + isInRange, + onSelect, + columns, + className, +}: DateSelectorDateSelectorPeriodGridProps) { + return ( +
+ {years.map((year) => ( +
+
+ {year} +
+
+ {items.map((item, index) => { + const isSelected = + selectedYear === year && selectedValue === index + const isRangeStart = + rangeStart?.year === year && rangeStart?.value === index + const isRangeEnd = + rangeEnd?.year === year && rangeEnd?.value === index + const inRange = isInRange(year, index) + + return ( + + ) + })} +
+
+ ))} +
+ ) +} + +interface DateSelectorYearListProps { + years: number[] + selectedYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isYearInRange: (year: number) => boolean + onSelect: (year: number) => void + className?: string +} + +function DateSelectorYearList({ + years, + selectedYear, + rangeStart, + rangeEnd, + isYearInRange, + onSelect, + className, +}: DateSelectorYearListProps) { + return ( +
+ {years.map((year) => { + const isSelected = selectedYear === year && !rangeStart && !rangeEnd + const isRangeStart = rangeStart?.year === year + const isRangeEnd = rangeEnd?.year === year + const inRange = isYearInRange(year) + + return ( + + ) + })} +
+ ) +} + +export interface DateSelectorProps { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + allowRange?: boolean + periodTypes?: DateSelectorPeriodType[] + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + showInput?: boolean + showTwoMonths?: boolean + label?: string + className?: string + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + i18n?: Partial + inputHint?: string + dayDateFormat?: string + dayDateFormats?: string[] + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 +} + +export function DateSelector({ + value, + onChange, + allowRange = true, + periodTypes, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + showInput = true, + showTwoMonths = true, + label, + className, + yearRange = 10, + baseYear, + minYear = 2015, + maxYear = 2026, + i18n: i18nOverride, + inputHint, + dayDateFormat = "MM/dd/yyyy", + dayDateFormats, + weekStartsOn, +}: DateSelectorProps) { + const mergedI18n = useMemo( + () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }), + [i18nOverride] + ) + + const selector = useDateSelector({ + value, + onChange, + defaultPeriodType, + defaultFilterType, + presetMode, + allowRange, + yearRange, + baseYear, + minYear, + maxYear, + periodTypes, + }) + + const { + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + setPeriodType, + setFilterType, + setCalendarMonth, + setHoverDate, + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } = selector + + const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat) + const [inputValue, setInputValue] = useState(displayValue) + const [isInputFocused, setIsInputFocused] = useState(false) + + // Sync input value when displayValue changes (but not when user is typing) + useEffect(() => { + if (!isInputFocused) { + setInputValue(displayValue) + } + }, [displayValue, isInputFocused]) + + // Compute date formats for parsing + const dateFormats = useMemo(() => { + if (dayDateFormats && dayDateFormats.length > 0) { + // Use provided formats, with dayDateFormat first if not already included + const formats = [...dayDateFormats] + if (!formats.includes(dayDateFormat)) { + formats.unshift(dayDateFormat) + } + return formats + } + // Default formats: use dayDateFormat first, then common alternatives + const defaultFormats = [ + dayDateFormat, + "dd/MM/yyyy", + "yyyy-MM-dd", + "MM-dd-yyyy", + "dd-MM-yyyy", + ] + // Remove duplicates while preserving order + return Array.from(new Set(defaultFormats)) + }, [dayDateFormat, dayDateFormats]) + + // Parse input text to DateSelectorValue + const parseInputValue = useCallback( + (text: string): DateSelectorValue | null => { + if (!text.trim()) return null + + const trimmed = text.trim() + + // Try parsing as year (e.g., "2025") + const yearMatch = trimmed.match(/^\d{4}$/) + if (yearMatch) { + const year = parseInt(yearMatch[0]) + if (year >= 1900 && year <= 2100) { + return { + period: "year", + operator: presetMode ?? filterType, + year, + } + } + } + + // Try parsing as quarter (e.g., "Q4", "Q1 2025") + const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i) + if (quarterMatch) { + const quarter = parseInt(quarterMatch[1]) - 1 + const year = quarterMatch[2] + ? parseInt(quarterMatch[2]) + : new Date().getFullYear() + if (year >= 1900 && year <= 2100) { + return { + period: "quarter", + operator: presetMode ?? filterType, + year, + quarter, + } + } + } + + // Try parsing as date using computed formats + for (const dateFormat of dateFormats) { + try { + const parsed = parse(trimmed, dateFormat, new Date()) + if (!isNaN(parsed.getTime())) { + return { + period: "day", + operator: presetMode ?? filterType, + startDate: parsed, + } + } + } catch { + // Continue to next format + } + } + + return null + }, + [filterType, presetMode, dateFormats] + ) + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value + setInputValue(newValue) + + // Try to parse the input + const parsed = parseInputValue(newValue) + if (parsed) { + onChange?.(parsed) + } + }, + [onChange, parseInputValue] + ) + + const handleInputBlur = useCallback(() => { + setIsInputFocused(false) + // Reset to display value if parsing failed + if (!parseInputValue(inputValue)) { + setInputValue(displayValue) + } + }, [inputValue, displayValue, parseInputValue]) + + return ( + +
+
+ {label && ( +

+ {label} +

+ )} + +
+ {showInput && ( +
+ setIsInputFocused(true)} + onBlur={handleInputBlur} + onChange={handleInputChange} + /> + {(inputHint ? inputValue : displayValue) && ( + + )} +
+ )} + + + {periodType === "day" ? ( +
+ +
+ ) : ( +
+ + {periodType === "month" && ( + + )} + + {periodType === "quarter" && ( + + )} + + {periodType === "half-year" && ( + + )} + + {periodType === "year" && ( + + )} + +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/reui/filters.tsx b/apps/web/src/components/reui/filters.tsx new file mode 100644 index 0000000..bc625d7 --- /dev/null +++ b/apps/web/src/components/reui/filters.tsx @@ -0,0 +1,1926 @@ +"use client" + +import type React from "react" +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" +import { useRender } from "@base-ui/react/use-render" +import { cva } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + ButtonGroup, + ButtonGroupText, +} from "@cfdm/ui/components/button-group" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { Input } from "@cfdm/ui/components/input" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, +} from "@cfdm/ui/components/input-group" +import { Kbd } from "@cfdm/ui/components/kbd" +import { ScrollArea } from "@cfdm/ui/components/scroll-area" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@cfdm/ui/components/tooltip" +import { AlertCircleIcon, XIcon, CheckIcon } from "lucide-react" + +// i18n Configuration Interface +export interface FilterI18nConfig { + // UI Labels + addFilter: string + searchFields: string + noFieldsFound: string + noResultsFound: string + select: string + true: string + false: string + min: string + max: string + to: string + typeAndPressEnter: string + selected: string + selectedCount: string + percent: string + defaultCurrency: string + defaultColor: string + addFilterTitle: string + + // Operators + operators: { + is: string + isNot: string + isAnyOf: string + isNotAnyOf: string + includesAll: string + excludesAll: string + before: string + after: string + between: string + notBetween: string + contains: string + notContains: string + startsWith: string + endsWith: string + isExactly: string + equals: string + notEquals: string + greaterThan: string + lessThan: string + overlaps: string + includes: string + excludes: string + includesAllOf: string + includesAnyOf: string + empty: string + notEmpty: string + } + + // Placeholders + placeholders: { + enterField: (fieldType: string) => string + selectField: string + searchField: (fieldName: string) => string + enterKey: string + enterValue: string + } + + // Helper functions + helpers: { + formatOperator: (operator: string) => string + } + + // Validation + validation: { + invalidEmail: string + invalidUrl: string + invalidTel: string + invalid: string + } +} + +// Default English i18n configuration +export const DEFAULT_I18N: FilterI18nConfig = { + // UI Labels + addFilter: "Filter", + searchFields: "Filter...", + noFieldsFound: "No filters found.", + noResultsFound: "No results found.", + select: "Select...", + true: "True", + false: "False", + min: "Min", + max: "Max", + to: "to", + typeAndPressEnter: "Type and press Enter to add tag", + selected: "selected", + selectedCount: "selected", + percent: "%", + defaultCurrency: "$", + defaultColor: "#000000", + addFilterTitle: "Add filter", + + // Operators + operators: { + is: "is", + isNot: "is not", + isAnyOf: "is any of", + isNotAnyOf: "is not any of", + includesAll: "includes all", + excludesAll: "excludes all", + before: "before", + after: "after", + between: "between", + notBetween: "not between", + contains: "contains", + notContains: "does not contain", + startsWith: "starts with", + endsWith: "ends with", + isExactly: "is exactly", + equals: "equals", + notEquals: "not equals", + greaterThan: "greater than", + lessThan: "less than", + overlaps: "overlaps", + includes: "includes", + excludes: "excludes", + includesAllOf: "includes all of", + includesAnyOf: "includes any of", + empty: "is empty", + notEmpty: "is not empty", + }, + + // Placeholders + placeholders: { + enterField: (fieldType: string) => `Enter ${fieldType}...`, + selectField: "Select...", + searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`, + enterKey: "Enter key...", + enterValue: "Enter value...", + }, + + // Helper functions + helpers: { + formatOperator: (operator: string) => operator.replace(/_/g, " "), + }, + + // Validation + validation: { + invalidEmail: "Invalid email format", + invalidUrl: "Invalid URL format", + invalidTel: "Invalid phone format", + invalid: "Invalid input format", + }, +} + +// Context for all Filter component props +interface FilterContextValue { + variant: "solid" | "default" + size: "sm" | "default" | "lg" + radius: "default" | "full" + i18n: FilterI18nConfig + className?: string + showSearchInput?: boolean + trigger?: React.ReactNode + allowMultiple?: boolean +} + +const FilterContext = createContext({ + variant: "default", + size: "default", + radius: "default", + i18n: DEFAULT_I18N, + className: undefined, + showSearchInput: true, + trigger: undefined, + allowMultiple: true, +}) + +const useFilterContext = () => useContext(FilterContext) + +// Container variant for filters wrapper +const filtersContainerVariants = cva("flex flex-wrap items-center", { + variants: { + variant: { + solid: "gap-2", + default: "", + }, + size: { + sm: "gap-1.5", + default: "gap-2.5", + lg: "gap-3.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, +}) + +function FilterInput({ + field, + onBlur, + onKeyDown, + className, + ...props +}: React.InputHTMLAttributes & { + className?: string + field?: FilterFieldConfig +}) { + const context = useFilterContext() + const [isValid, setIsValid] = useState(true) + const [validationMessage, setValidationMessage] = useState("") + const inputRef = useRef(null) + + useEffect(() => { + if (props.autoFocus) { + const timer = setTimeout(() => { + inputRef.current?.focus() + }, 300) + return () => clearTimeout(timer) + } + }, [props.autoFocus]) + + // Validation function to check if input matches pattern + const validateInput = (value: string, pattern?: string): boolean => { + if (!pattern || !value) return true + const regex = new RegExp(pattern) + return regex.test(value) + } + + // Get validation message for field type + const getValidationMessage = (): string => { + return context.i18n.validation.invalid + } + + // Handle blur event - validate when user leaves input + const handleBlur = (e: React.FocusEvent) => { + const value = e.target.value + const pattern = field?.pattern || props.pattern + + // Only validate if there's a value and (pattern or validation function) + if (value && (pattern || field?.validation)) { + let valid = true + let customMessage = "" + + // If there's a custom validation function, use it + if (field?.validation) { + const result = field.validation(value) + // Handle both boolean and object return types + if (typeof result === "boolean") { + valid = result + } else { + valid = result.valid + customMessage = result.message || "" + } + } else if (pattern) { + // Use pattern validation + valid = validateInput(value, pattern) + } + + setIsValid(valid) + setValidationMessage(valid ? "" : customMessage || getValidationMessage()) + } else { + // Reset validation state for empty values or no validation + setIsValid(true) + setValidationMessage("") + } + + // Call the original onBlur if provided + onBlur?.(e) + } + + // Handle keydown event - hide validation error when user starts typing + const handleKeyDown = (e: React.KeyboardEvent) => { + // Hide validation error when user starts typing (any key except special keys) + if ( + !isValid && + ![ + "Tab", + "Escape", + "Enter", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + ].includes(e.key) + ) { + setIsValid(true) + setValidationMessage("") + } + + // Call the original onKeyDown if provided + onKeyDown?.(e) + } + + return ( + + {field?.prefix && ( + + {field.prefix} + + )} + + {!isValid && validationMessage && ( + + + }> + + + +

{validationMessage}

+
+
+
+ )} + + {field?.suffix && ( + + {field.suffix} + + )} +
+ ) +} + +interface FilterRemoveButtonProps extends React.ButtonHTMLAttributes { + icon?: React.ReactNode +} + +function FilterRemoveButton({ + className, + icon = ( + + ), + ...props +}: FilterRemoveButtonProps) { + const context = useFilterContext() + + return ( + + ) +} + +// Generic types for flexible filter system +export interface FilterOption { + value: T + label: string + icon?: React.ReactNode + metadata?: Record + className?: string +} + +export interface FilterOperator { + value: string + label: string + supportsMultiple?: boolean +} + +// Custom renderer props interface +export interface CustomRendererProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + operator: string +} + +// Grouped field configuration interface +export interface FilterFieldGroup { + group?: string + fields: FilterFieldConfig[] +} + +// Union type for both flat and grouped field configurations +export type FilterFieldsConfig = + | FilterFieldConfig[] + | FilterFieldGroup[] + +export interface FilterFieldConfig { + key?: string + label?: string + icon?: React.ReactNode + type?: "select" | "multiselect" | "text" | "custom" | "separator" + // Group-level configuration + group?: string + fields?: FilterFieldConfig[] + // Field-specific options + options?: FilterOption[] + operators?: FilterOperator[] + customRenderer?: (props: CustomRendererProps) => React.ReactNode + customValueRenderer?: ( + values: T[], + options: FilterOption[] + ) => React.ReactNode + placeholder?: string + searchable?: boolean + maxSelections?: number + min?: number + max?: number + step?: number + prefix?: string | React.ReactNode + suffix?: string | React.ReactNode + pattern?: string + validation?: ( + value: unknown + ) => boolean | { valid: boolean; message?: string } + allowCustomValues?: boolean + className?: string + menuPopupClassName?: string + // Grouping options (legacy support) + groupLabel?: string + // Boolean field options + onLabel?: string + offLabel?: string + // Input event handlers + onInputChange?: (e: React.ChangeEvent) => void + // Default operator to use when creating a filter for this field + defaultOperator?: string + // Controlled values support for this field + value?: T[] + onValueChange?: (values: T[]) => void +} + +// Helper functions to handle both flat and grouped field configurations +const isFieldGroup = ( + item: FilterFieldConfig | FilterFieldGroup +): item is FilterFieldGroup => { + return "fields" in item && Array.isArray(item.fields) +} + +// Helper function to check if a FilterFieldConfig is a group-level configuration +const isGroupLevelField = ( + field: FilterFieldConfig +): boolean => { + return Boolean(field.group && field.fields) +} + +const flattenFields = ( + fields: FilterFieldsConfig +): FilterFieldConfig[] => { + return fields.reduce[]>((acc, item) => { + if (isFieldGroup(item)) { + return [...acc, ...item.fields] + } + // Handle group-level fields (new structure) + if (isGroupLevelField(item)) { + return [...acc, ...item.fields!] + } + return [...acc, item] + }, []) +} + +const getFieldsMap = ( + fields: FilterFieldsConfig +): Record> => { + const flatFields = flattenFields(fields) + return flatFields.reduce( + (acc, field) => { + // Only add fields that have a key (skip group-level configurations) + if (field.key) { + acc[field.key] = field + } + return acc + }, + {} as Record> + ) +} + +// Helper function to create operators from i18n config +const createOperatorsFromI18n = ( + i18n: FilterI18nConfig +): Record => ({ + select: [ + { value: "is", label: i18n.operators.is }, + { value: "is_not", label: i18n.operators.isNot }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + multiselect: [ + { value: "is_any_of", label: i18n.operators.isAnyOf }, + { value: "is_not_any_of", label: i18n.operators.isNotAnyOf }, + { value: "includes_all", label: i18n.operators.includesAll }, + { value: "excludes_all", label: i18n.operators.excludesAll }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + text: [ + { value: "contains", label: i18n.operators.contains }, + { value: "not_contains", label: i18n.operators.notContains }, + { value: "starts_with", label: i18n.operators.startsWith }, + { value: "ends_with", label: i18n.operators.endsWith }, + { value: "is", label: i18n.operators.isExactly }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + custom: [ + { value: "is", label: i18n.operators.is }, + { value: "after", label: i18n.operators.after }, + { value: "is", label: i18n.operators.is }, + { value: "between", label: i18n.operators.between }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], +}) + +// Default operators for different field types (using default i18n) +export const DEFAULT_OPERATORS: Record = + createOperatorsFromI18n(DEFAULT_I18N) + +// Helper function to get operators for a field +const getOperatorsForField = ( + field: FilterFieldConfig, + values: T[], + i18n: FilterI18nConfig +): FilterOperator[] => { + if (field.operators) return field.operators + + const operators = createOperatorsFromI18n(i18n) + + // Determine field type for operator selection + let fieldType = field.type || "select" + + // If it's a select field but has multiple values, treat as multiselect + if (fieldType === "select" && values.length > 1) { + fieldType = "multiselect" + } + + // If it's a multiselect field or has multiselect operators, use multiselect operators + if (fieldType === "multiselect" || field.type === "multiselect") { + return operators.multiselect + } + + return operators[fieldType] || operators.select +} + +interface FilterOperatorDropdownProps { + field: FilterFieldConfig + operator: string + values: T[] + onChange: (operator: string) => void +} + +function FilterOperatorDropdown({ + field, + operator, + values, + onChange, +}: FilterOperatorDropdownProps) { + const context = useFilterContext() + const operators = getOperatorsForField(field, values, context.i18n) + + // Find the operator label, with fallback to formatted operator name + const operatorLabel = + operators.find((op) => op.value === operator)?.label || + context.i18n.helpers.formatOperator(operator) + + return ( + + + {operatorLabel} + + } + /> + + {operators.map((op) => ( + onChange(op.value)} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground flex items-center justify-between" + )} + > + {op.label} + + + ))} + + + ) +} + +interface FilterValueSelectorProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + operator: string + autoFocus?: boolean +} + +interface SelectOptionsPopoverProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + onClose?: () => void + inline?: boolean +} + +function SelectOptionsPopover({ + field, + values, + onChange, + onClose, + inline = false, +}: SelectOptionsPopoverProps) { + const [open, setOpen] = useState(false) + const [searchInput, setSearchInput] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const context = useFilterContext() + const baseId = useId() + + useEffect(() => { + if (open) { + inputRef.current?.focus() + } + }, [open]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [searchInput, open]) + + useEffect(() => { + if (highlightedIndex >= 0 && open) { + const element = document.getElementById( + `${baseId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, open, baseId]) + + const isMultiSelect = field.type === "multiselect" || values.length > 1 + const effectiveValues = + (field.value !== undefined ? (field.value as T[]) : values) || [] + + const selectedOptions = + field.options?.filter((opt) => effectiveValues.includes(opt.value)) || [] + const unselectedOptions = + field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || [] + + // Filter options based on search input + const filteredSelectedOptions = selectedOptions // Keep all selected visible + const filteredUnselectedOptions = unselectedOptions.filter((opt) => + opt.label.toLowerCase().includes(searchInput.toLowerCase()) + ) + + const allFilteredOptions = useMemo( + () => [...filteredSelectedOptions, ...filteredUnselectedOptions], + [filteredSelectedOptions, filteredUnselectedOptions] + ) + + const handleClose = () => { + setOpen(false) + onClose?.() + } + + const renderMenuContent = () => ( + <> + {field.searchable !== false && ( + <> + = 0 + ? `${baseId}-item-${highlightedIndex}` + : undefined + } + placeholder={context.i18n.placeholders.searchField( + field.label || "" + )} + className={cn( + "border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + open && "placeholder:text-foreground" + )} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} + onBlur={() => open && inputRef.current?.focus()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (allFilteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < allFilteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (allFilteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : allFilteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + setOpen(false) + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = allFilteredOptions[highlightedIndex] + if (option) { + const isSelected = effectiveValues.includes(option.value as T) + const next = isSelected + ? (effectiveValues.filter((v) => v !== option.value) as T[]) + : isMultiSelect + ? ([...effectiveValues, option.value] as T[]) + : ([option.value] as T[]) + + if ( + !isSelected && + isMultiSelect && + field.maxSelections && + next.length > field.maxSelections + ) { + return + } + + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + } + } + e.stopPropagation() + }} + /> + + + )} +
+
+ + {allFilteredOptions.length === 0 && ( +
+ {context.i18n.noResultsFound} +
+ )} + + {/* Selected items */} + {filteredSelectedOptions.length > 0 && ( + + {filteredSelectedOptions.map((option, index) => { + const isHighlighted = highlightedIndex === index + const itemId = `${baseId}-item-${index}` + + return ( + setHighlightedIndex(index)} + checked={true} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => { + const next = effectiveValues.filter( + (v) => v !== option.value + ) as T[] + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + }} + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} + + {/* Separator */} + {filteredSelectedOptions.length > 0 && + filteredUnselectedOptions.length > 0 && ( + + )} + + {/* Available items */} + {filteredUnselectedOptions.length > 0 && ( + + {filteredUnselectedOptions.map((option, index) => { + const overallIndex = index + filteredSelectedOptions.length + const isHighlighted = highlightedIndex === overallIndex + const itemId = `${baseId}-item-${overallIndex}` + + return ( + setHighlightedIndex(overallIndex)} + checked={false} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => { + const next = isMultiSelect + ? ([...effectiveValues, option.value] as T[]) + : ([option.value] as T[]) + + if ( + isMultiSelect && + field.maxSelections && + next.length > field.maxSelections + ) { + return + } + + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + }} + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} +
+
+
+ + ) + + if (inline) { + return
{renderMenuContent()}
+ } + + return ( + { + setOpen(open) + if (!open) { + setTimeout(() => setSearchInput(""), 200) + } + }} + > + +
+ {field.customValueRenderer ? ( + field.customValueRenderer(values, field.options || []) + ) : ( + <> + {selectedOptions.length > 0 && ( +
+ {selectedOptions.slice(0, 3).map((option) => ( +
{option.icon}
+ ))} +
+ )} + {selectedOptions.length === 1 + ? selectedOptions[0].label + : selectedOptions.length > 1 + ? `${selectedOptions.length} ${context.i18n.selectedCount}` + : context.i18n.select} + + )} +
+ + } + /> + + {renderMenuContent()} + +
+ ) +} + +function FilterValueSelector({ + field, + values, + onChange, + operator, + autoFocus, +}: FilterValueSelectorProps) { + if (operator === "empty" || operator === "not_empty") { + return null + } + + if (field.customRenderer) { + return ( + + {field.customRenderer({ field, values, onChange, operator })} + + ) + } + + if (field.type === "text") { + return ( + onChange([e.target.value] as T[])} + placeholder={field.placeholder} + pattern={field.pattern} + field={field} + className={cn("w-36", field.className)} + autoFocus={autoFocus} + /> + ) + } + + if (field.type === "select" || field.type === "multiselect") { + return ( + + ) + } + + return ( + + ) +} +export interface Filter { + id: string + field: string + operator: string + values: T[] +} + +export interface FilterGroup { + id: string + label?: string + filters: Filter[] + fields: FilterFieldConfig[] +} + +interface FiltersContentProps { + filters: Filter[] + fields: FilterFieldsConfig + onChange: (filters: Filter[]) => void +} + +export const FiltersContent = ({ + filters, + fields, + onChange, +}: FiltersContentProps) => { + const context = useFilterContext() + const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) + + const updateFilter = useCallback( + (filterId: string, updates: Partial>) => { + onChange( + filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilter = { ...filter, ...updates } + if ( + updates.operator === "empty" || + updates.operator === "not_empty" + ) { + updatedFilter.values = [] as T[] + } + return updatedFilter + } + return filter + }) + ) + }, + [filters, onChange] + ) + + const removeFilter = useCallback( + (filterId: string) => { + onChange(filters.filter((filter) => filter.id !== filterId)) + }, + [filters, onChange] + ) + + return ( +
+ {filters.map((filter) => { + const field = fieldsMap[filter.field] + if (!field) return null + + return ( + + + {field.icon && field.icon} + {field.label} + + + + field={field} + operator={filter.operator} + values={filter.values} + onChange={(operator) => updateFilter(filter.id, { operator })} + /> + + + field={field} + values={filter.values} + onChange={(values) => updateFilter(filter.id, { values })} + operator={filter.operator} + autoFocus={false} + /> + + removeFilter(filter.id)} /> + + ) + })} +
+ ) +} + +interface FiltersProps { + filters: Filter[] + fields: FilterFieldsConfig + onChange: (filters: Filter[]) => void + className?: string + variant?: "solid" | "default" + size?: "sm" | "default" | "lg" + radius?: "default" | "full" + i18n?: Partial + showSearchInput?: boolean + trigger?: React.ReactNode + allowMultiple?: boolean + menuPopupClassName?: string + collapseAddButton?: boolean + enableShortcut?: boolean + shortcutKey?: string + shortcutLabel?: string +} + +interface FilterSubmenuContentProps { + field: FilterFieldConfig + currentValues: T[] + isMultiSelect: boolean + onToggle: (value: T, isSelected: boolean) => void + i18n: FilterI18nConfig + isActive?: boolean + onActive?: () => void + onBack?: () => void + onClose?: () => void +} + +function FilterSubmenuContent({ + field, + currentValues, + isMultiSelect, + onToggle, + i18n, + isActive, + onActive, + onBack, + onClose, +}: FilterSubmenuContentProps) { + const [searchInput, setSearchInput] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const baseId = useId() + + useEffect(() => { + if (isActive) { + if (field.searchable !== false) { + inputRef.current?.focus() + } else { + const listbox = document.getElementById(`${baseId}-listbox`) + listbox?.focus() + } + } + }, [isActive, field.searchable, baseId]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [searchInput]) + + useEffect(() => { + if (highlightedIndex >= 0 && isActive) { + const element = document.getElementById( + `${baseId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, isActive, baseId]) + + const filteredOptions = useMemo(() => { + return ( + field.options?.filter((option) => { + const isSelected = currentValues.includes(option.value) + if (isSelected) return true + if (!searchInput) return true + return option.label.toLowerCase().includes(searchInput.toLowerCase()) + }) || [] + ) + }, [field.options, searchInput, currentValues]) + + useEffect(() => { + if (isActive && filteredOptions.length > 0) { + setHighlightedIndex(0) + } + }, [isActive, filteredOptions.length]) + + return ( +
+ {field.searchable !== false && ( + <> + = 0 + ? `${baseId}-item-${highlightedIndex}` + : undefined + } + placeholder={i18n.placeholders.searchField(field.label || "")} + className={cn( + "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + isActive && "placeholder:text-foreground" + )} + value={searchInput} + onBlur={() => isActive && inputRef.current?.focus()} + onChange={(e) => setSearchInput(e.target.value)} + onFocus={() => onActive?.()} + onMouseEnter={(e) => { + onActive?.() + e.stopPropagation() + }} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + onBack?.() + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = filteredOptions[highlightedIndex] + if (option) { + onToggle( + option.value as T, + currentValues.includes(option.value) + ) + if (!isMultiSelect) { + onBack?.() + } + } + } else if (e.key === "Escape") { + e.preventDefault() + onClose?.() + } + e.stopPropagation() + }} + /> + + + )} +
+
{ + if (field.searchable === false) { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + onBack?.() + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = filteredOptions[highlightedIndex] + if (option) { + onToggle( + option.value as T, + currentValues.includes(option.value) + ) + if (!isMultiSelect) { + onBack?.() + } + } + } else if (e.key === "Escape") { + e.preventDefault() + onClose?.() + } + e.stopPropagation() + } + }} + > + + {filteredOptions.length === 0 ? ( +
+ {i18n.noResultsFound} +
+ ) : ( + + {filteredOptions.map((option, index) => { + const isSelected = currentValues.includes(option.value) + const isHighlighted = highlightedIndex === index + const itemId = `${baseId}-item-${index}` + + return ( + setHighlightedIndex(index)} + checked={isSelected} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => + onToggle(option.value as T, isSelected) + } + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} +
+
+
+
+ ) +} + +export function Filters({ + filters, + fields, + onChange, + className, + variant = "default", + size = "default", + radius = "default", + i18n, + showSearchInput = true, + trigger, + allowMultiple = true, + menuPopupClassName, + enableShortcut = false, + shortcutKey = "f", + shortcutLabel = "F", +}: FiltersProps) { + const [addFilterOpen, setAddFilterOpen] = useState(false) + const [menuSearchInput, setMenuSearchInput] = useState("") + const [activeMenu, setActiveMenu] = useState("root") + const [openSubMenu, setOpenSubMenu] = useState(null) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [lastAddedFilterId, setLastAddedFilterId] = useState( + null + ) + const rootInputRef = useRef(null) + const rootId = useId() + + useEffect(() => { + if (!enableShortcut) return + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key.toLowerCase() === shortcutKey.toLowerCase() && + !addFilterOpen && + !( + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement + ) + ) { + e.preventDefault() + setAddFilterOpen(true) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [enableShortcut, shortcutKey, addFilterOpen]) + + useEffect(() => { + if (addFilterOpen && activeMenu === "root") { + rootInputRef.current?.focus() + } + }, [addFilterOpen, activeMenu]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [menuSearchInput]) + + useEffect(() => { + if (highlightedIndex >= 0 && addFilterOpen) { + const element = document.getElementById( + `${rootId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, addFilterOpen, rootId]) + + useEffect(() => { + if (!addFilterOpen) { + setOpenSubMenu(null) + } + }, [addFilterOpen]) + + // Track which filter instance is being built in the current Add Filter menu session + // Maps fieldKey -> unique filterId created during this open session + const [sessionFilterIds, setSessionFilterIds] = useState< + Record + >({}) + + useEffect(() => { + if (lastAddedFilterId) { + const timer = setTimeout(() => { + setLastAddedFilterId(null) + }, 1000) + return () => clearTimeout(timer) + } + }, [lastAddedFilterId]) + + const mergedI18n: FilterI18nConfig = { + ...DEFAULT_I18N, + ...i18n, + operators: { ...DEFAULT_I18N.operators, ...i18n?.operators }, + placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders }, + validation: { ...DEFAULT_I18N.validation, ...i18n?.validation }, + } + + const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) + + const updateFilter = useCallback( + (filterId: string, updates: Partial>) => { + onChange( + filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilter = { ...filter, ...updates } + if ( + updates.operator === "empty" || + updates.operator === "not_empty" + ) { + updatedFilter.values = [] as T[] + } + return updatedFilter + } + return filter + }) + ) + }, + [filters, onChange] + ) + + const removeFilter = useCallback( + (filterId: string) => { + onChange(filters.filter((filter) => filter.id !== filterId)) + }, + [filters, onChange] + ) + + const addFilter = useCallback( + (fieldKey: string) => { + const field = fieldsMap[fieldKey] + if (field && field.key) { + const defaultOperator = + field.defaultOperator || + (field.type === "multiselect" ? "is_any_of" : "is") + const defaultValues: unknown[] = field.type === "text" ? [""] : [] + const newFilter = createFilter( + fieldKey, + defaultOperator, + defaultValues as T[] + ) + setLastAddedFilterId(newFilter.id) + onChange([...filters, newFilter]) + setAddFilterOpen(false) + setMenuSearchInput("") + } + }, + [fieldsMap, filters, onChange] + ) + + useEffect(() => { + if (addFilterOpen && activeMenu === "root") { + rootInputRef.current?.focus() + } + }, [addFilterOpen, activeMenu]) + + const selectableFields = useMemo(() => { + const flatFields = flattenFields(fields) + return flatFields.filter((field) => { + if (!field.key || field.type === "separator") return false + if (allowMultiple) return true + return !filters.some((filter) => filter.field === field.key) + }) + }, [fields, filters, allowMultiple]) + + const filteredFields = useMemo(() => { + return selectableFields.filter( + (f) => + !menuSearchInput || + f.label?.toLowerCase().includes(menuSearchInput.toLowerCase()) + ) + }, [selectableFields, menuSearchInput]) + + useEffect(() => { + if (addFilterOpen && filteredFields.length > 0) { + setHighlightedIndex(0) + } + }, [addFilterOpen, filteredFields.length]) + + const triggerButton = useRender({ + render: trigger as React.ReactElement, + defaultTagName: "button", + }) + + return ( + +
+ {selectableFields.length > 0 && ( + { + setAddFilterOpen(open) + if (!open) { + setMenuSearchInput("") + setSessionFilterIds({}) + } else { + setActiveMenu("root") + } + }} + > + + + {showSearchInput && ( + <> +
+ = 0 + ? `${rootId}-item-${highlightedIndex}` + : undefined + } + placeholder={mergedI18n.searchFields} + className={cn( + "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + activeMenu === "root" && "placeholder:text-foreground" + )} + value={menuSearchInput} + onFocus={() => setActiveMenu("root")} + onMouseEnter={() => setActiveMenu("root")} + onBlur={() => + activeMenu === "root" && rootInputRef.current?.focus() + } + onChange={(e) => setMenuSearchInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredFields.length > 0) { + setHighlightedIndex((prev) => + prev < filteredFields.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredFields.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredFields.length - 1 + ) + } + } else if ( + (e.key === "ArrowRight" || e.key === "ArrowLeft") && + highlightedIndex >= 0 + ) { + const field = filteredFields[highlightedIndex] + const hasSubMenu = + field && + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + + if (e.key === "ArrowRight" && hasSubMenu) { + e.preventDefault() + setOpenSubMenu(field.key || null) + setActiveMenu(field.key || "root") + } else if (e.key === "ArrowLeft") { + e.preventDefault() + if (openSubMenu) { + setOpenSubMenu(null) + setActiveMenu("root") + } + } + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const field = filteredFields[highlightedIndex] + if (field.key) { + const hasSubMenu = + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + if (!hasSubMenu) { + addFilter(field.key) + } else { + if (openSubMenu === field.key) { + setOpenSubMenu(null) + setActiveMenu("root") + } else { + setOpenSubMenu(field.key) + setActiveMenu(field.key) + } + } + } + } else if (e.key === "Escape") { + setAddFilterOpen(false) + } + e.stopPropagation() + }} + /> + {enableShortcut && shortcutLabel && ( + + {shortcutLabel} + + )} +
+ + + )} + +
+
setActiveMenu("root")} + > + + {(() => { + if (filteredFields.length === 0) { + return ( +
+ {mergedI18n.noFieldsFound} +
+ ) + } + + return filteredFields.map((field, index) => { + const isHighlighted = highlightedIndex === index + const itemId = `${rootId}-item-${index}` + const hasSubMenu = + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + + if (hasSubMenu) { + const isMultiSelect = field.type === "multiselect" + const fieldKey = field.key as string + const sessionFilterId = sessionFilterIds[fieldKey] + const sessionFilter = sessionFilterId + ? filters.find((f) => f.id === sessionFilterId) + : null + const currentValues = sessionFilter?.values || [] + + return ( + { + if (open) { + setOpenSubMenu(fieldKey) + } else { + if (openSubMenu === fieldKey) { + setOpenSubMenu(null) + setActiveMenu("root") + } + } + }} + > + { + setHighlightedIndex(index) + setActiveMenu("root") + }} + className="data-popup-open:bg-accent data-popup-open:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {field.icon} + {field.label} + + + { + if (field.searchable !== false) { + setActiveMenu(fieldKey) + } + }} + onBack={() => { + setOpenSubMenu(null) + setActiveMenu("root") + }} + onClose={() => setAddFilterOpen(false)} + onToggle={(value, isSelected) => { + if (isMultiSelect) { + const nextValues = isSelected + ? (currentValues.filter( + (v) => v !== value + ) as T[]) + : ([...currentValues, value] as T[]) + + if (sessionFilter) { + if (nextValues.length === 0) { + onChange( + filters.filter( + (f) => f.id !== sessionFilter.id + ) + ) + setSessionFilterIds((prev) => ({ + ...prev, + [fieldKey]: "", + })) + } else { + onChange( + filters.map((f) => + f.id === sessionFilter.id + ? { ...f, values: nextValues } + : f + ) + ) + } + } else { + const newFilter = createFilter( + fieldKey, + field.defaultOperator || "is_any_of", + nextValues + ) + onChange([...filters, newFilter]) + setSessionFilterIds((prev) => ({ + ...prev, + [fieldKey]: newFilter.id, + })) + } + } else { + const newFilter = createFilter( + fieldKey, + field.defaultOperator || "is", + [value] as T[] + ) + setLastAddedFilterId(newFilter.id) + onChange([...filters, newFilter]) + setAddFilterOpen(false) + } + }} + /> + + + ) + } + + return ( + setHighlightedIndex(index)} + onClick={() => field.key && addFilter(field.key)} + className="data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {field.icon} + {field.label} + + ) + }) + })()} +
+
+
+
+
+ )} + + {filters.map((filter) => { + const field = fieldsMap[filter.field] + if (!field) return null + return ( + + + {field.icon && field.icon} + {field.label} + + + field={field} + operator={filter.operator} + values={filter.values} + onChange={(operator) => updateFilter(filter.id, { operator })} + /> + + field={field} + values={filter.values} + operator={filter.operator} + onChange={(values) => updateFilter(filter.id, { values })} + autoFocus={filter.id === lastAddedFilterId} + /> + removeFilter(filter.id)} /> + + ) + })} +
+
+ ) +} + +export const createFilter = ( + field: string, + operator?: string, + values: T[] = [] +): Filter => ({ + id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + field, + operator: operator || "is", + values, +}) + +export const createFilterGroup = ( + id: string, + label: string, + fields: FilterFieldConfig[], + initialFilters: Filter[] = [] +): FilterGroup => ({ + id, + label, + filters: initialFilters, + fields, +}) \ No newline at end of file diff --git a/apps/web/src/components/reui/number-field.tsx b/apps/web/src/components/reui/number-field.tsx new file mode 100644 index 0000000..e12c156 --- /dev/null +++ b/apps/web/src/components/reui/number-field.tsx @@ -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) { + const generatedId = useId() + const fieldId = id ?? generatedId + const sizeValue = size ?? "default" + + return ( + + + + ) +} + +function NumberFieldGroup({ + className, + size: sizeProp, + ...props +}: NumberFieldPrimitive.Group.Props & + Partial>) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldGroup must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + ) +} + +function NumberFieldDecrement({ + className, + size: sizeProp, + children, + ...props +}: NumberFieldPrimitive.Decrement.Props & + Partial> & { + 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 ( + + {children ?? ( + + )} + + ) +} + +function NumberFieldIncrement({ + className, + size: sizeProp, + children, + ...props +}: NumberFieldPrimitive.Increment.Props & + Partial> & { + 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 ( + + {children ?? ( + + )} + + ) +} + +function NumberFieldInput({ + className, + size: sizeProp, + ...props +}: NumberFieldPrimitive.Input.Props & + Partial>) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldInput must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + ) +} + +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 ( + + + + + + + ) +} + +function CursorGrowIcon(props: React.ComponentProps<"svg">) { + return ( + + + + ) +} + +export { + NumberField, + NumberFieldScrubArea, + NumberFieldDecrement, + NumberFieldIncrement, + NumberFieldGroup, + NumberFieldInput, +} \ No newline at end of file diff --git a/apps/web/src/components/vps-filters-toolbar.tsx b/apps/web/src/components/vps-filters-toolbar.tsx new file mode 100644 index 0000000..a6a6ec3 --- /dev/null +++ b/apps/web/src/components/vps-filters-toolbar.tsx @@ -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 | Filter)[] { + const out: (Filter | Filter)[] = [] + if (state.providerId.length) out.push(createFilter('providerId', 'is_any_of', state.providerId)) + if (state.providerAccountId.length) out.push(createFilter('providerAccountId', 'is_any_of', state.providerAccountId)) + if (state.country.length) out.push(createFilter('country', 'is_any_of', state.country)) + if (state.city.length) out.push(createFilter('city', 'is_any_of', state.city)) + if (state.datacenter) out.push(createFilter('datacenter', 'contains', [state.datacenter])) + if (state.status.length) out.push(createFilter('status', 'is_any_of', state.status)) + if (state.environment.length) out.push(createFilter('environment', 'is_any_of', state.environment)) + if (state.tariffType.length) out.push(createFilter('tariffType', 'is_any_of', state.tariffType)) + if (state.monitoring.length) out.push(createFilter('monitoring', 'is_any_of', state.monitoring)) + if (state.backup.length) out.push(createFilter('backup', 'is_any_of', state.backup)) + if (state.project.length) out.push(createFilter('project', 'is_any_of', state.project)) + if (state.minVcpu != null) out.push(createFilter('minVcpu', 'is', [state.minVcpu])) + if (state.minRamGb != null) out.push(createFilter('minRamGb', 'is', [state.minRamGb])) + if (state.minDiskGb != null) out.push(createFilter('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(() => loadFilterPresets()) + + const reuiFilters = useMemo<(Filter | Filter)[]>(() => stateToFilters(filters), [filters]) + + const fields = useMemo<(FilterFieldConfig | FilterFieldConfig)[]>(() => { + const count = (pred: (v: Vps) => boolean) => vps.filter(pred).length + + const providerOpts: FilterOption[] = providers.map((p) => ({ + value: p.id, + label: p.name, + metadata: { count: count((v) => v.providerId === p.id) }, + })) + + const accountOpts: FilterOption[] = providerAccounts.map((a) => ({ + value: a.id, + label: a.name, + metadata: { count: count((v) => v.providerAccountId === a.id) }, + })) + + const countryOpts: FilterOption[] = countryOptions.map((c) => ({ + value: c.value, + label: c.label, + icon: c.code ? {getCountryFlagEmojiByCode(c.code)} : undefined, + metadata: { count: count((v) => (v.country ?? '').trim() === c.value) }, + })) + + const cityOpts: FilterOption[] = cityOptions.map((c) => ({ + value: c.value, + label: c.label, + metadata: { count: count((v) => (v.city ?? '').trim() === c.value) }, + })) + + const projectOpts: FilterOption[] = [ + { 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[] = [ + { value: 'active', label: vpsStatusLabel('active') }, + { value: 'paused', label: vpsStatusLabel('paused') }, + { value: 'archived', label: vpsStatusLabel('archived') }, + ] + + const envOpts: FilterOption[] = [ + { value: 'prod', label: environmentLabel('prod') }, + { value: 'dev', label: environmentLabel('dev') }, + { value: 'staging', label: environmentLabel('staging') }, + ] + + const tariffOpts: FilterOption[] = [ + { value: 'monthly', label: tariffTypeLabel('monthly') }, + { value: 'daily', label: tariffTypeLabel('daily') }, + ] + + const onOffOpts: FilterOption[] = [ + { 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 }) => ( +
+ onCh([typeof v === 'number' ? v : (v[0] ?? 0)])} + className="flex-1" + /> + {values[0] ?? 0} +
+ ), + }, + { + 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 }) => ( +
+ onCh([typeof v === 'number' ? v : (v[0] ?? 0)])} + className="flex-1" + /> + {values[0] ?? 0} GB +
+ ), + }, + { + 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 }) => ( +
+ onCh([typeof v === 'number' ? v : (v[0] ?? 0)])} + className="flex-1" + /> + {values[0] ?? 0} GB +
+ ), + }, + ] + }, [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 ( +
+
+ + onChange({ ...filters, search: e.target.value })} + className="pl-8" + /> +
+ + + + + + + Вид + + } + /> + +
+
+ + + +
+ + + +
+
+ + +
+ {presets.length === 0 ? ( +

Нет сохранённых пресетов

+ ) : ( +
+ {presets.map((p) => ( +
+ + +
+ ))} +
+ )} +
+
+
+
+ + {hasActive ? ( + + ) : null} +
+ ) +} + +// Обратная совместимость — не используется напрямую, но экспортируем +export { stateToActiveFilters } diff --git a/apps/web/src/components/vps-filters.tsx b/apps/web/src/components/vps-filters.tsx index b1e0a4e..3ed6e52 100644 --- a/apps/web/src/components/vps-filters.tsx +++ b/apps/web/src/components/vps-filters.tsx @@ -1,40 +1,27 @@ -import { useMemo } from 'react' -import { SearchIcon, FilterIcon, XIcon, SaveIcon, TrashIcon } from 'lucide-react' - -import { Input } from '@cfdm/ui/components/input' -import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' -import { Card, CardContent } from '@cfdm/ui/components/card' -import { Checkbox } from '@cfdm/ui/components/checkbox' -import { Label } from '@cfdm/ui/components/label' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@cfdm/ui/components/dropdown-menu' - -import { SelectField } from '@/components/select-field' -import { AutoCompleteInput, type AutoCompleteOption } from '@/components/auto-complete-input' -import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format' -import type { Provider, ProviderAccount, Vps } from '@/types/entities' +import type { Vps } from '@/types/entities' +/** + * Состояние фильтров VPS. + * Multiselect-поля (status, environment, tariffType, monitoring, backup, + * providerId, providerAccountId, project, country, city) — массивы строк. + * Пустой массив = «все». + */ export interface VpsFiltersState { search: string - providerId: string - providerAccountId: string - country: string - city: string + providerId: string[] + providerAccountId: string[] + country: string[] + city: string[] datacenter: string - status: string // 'all' | VpsStatus - environment: string // 'all' | env - tariffType: string // 'all' | TariffType - monitoring: string // 'all' | 'on' | 'off' - backup: string // 'all' | 'on' | 'off' - minVcpu: string - minRamGb: string - minDiskGb: string - project: string // '' | '__none__' | name + status: string[] + environment: string[] + tariffType: string[] + monitoring: string[] + backup: string[] + minVcpu: number | null + minRamGb: number | null + minDiskGb: number | null + project: string[] groupByProject: boolean tableCompact: boolean } @@ -42,60 +29,220 @@ export interface VpsFiltersState { export function buildDefaultVpsFilters(): VpsFiltersState { return { search: '', - providerId: '', - providerAccountId: '', - country: '', - city: '', + providerId: [], + providerAccountId: [], + country: [], + city: [], datacenter: '', - status: 'all', - environment: 'all', - tariffType: 'all', - monitoring: 'all', - backup: 'all', - minVcpu: '', - minRamGb: '', - minDiskGb: '', - project: '', + status: [], + environment: [], + tariffType: [], + monitoring: [], + backup: [], + minVcpu: null, + minRamGb: null, + minDiskGb: null, + project: [], groupByProject: false, tableCompact: false, } } -const ALL = 'all' +const arrOr = (v: T[] | T | undefined | null): T[] => + Array.isArray(v) ? v : v == null || v === '' ? [] : [v] -const STATUS_OPTIONS = [ - { value: ALL, label: 'Все статусы' }, - { value: 'active', label: vpsStatusLabel('active') }, - { value: 'paused', label: vpsStatusLabel('paused') }, - { value: 'archived', label: vpsStatusLabel('archived') }, -] +const matchesAny = (item: T | undefined, values: T[]): boolean => { + if (values.length === 0) return true + if (item == null) return false + return values.includes(item) +} -const ENV_OPTIONS = [ - { value: ALL, label: 'Все окружения' }, - { value: 'prod', label: environmentLabel('prod') }, - { value: 'dev', label: environmentLabel('dev') }, - { value: 'staging', label: environmentLabel('staging') }, -] +const matchesText = ( + item: string | undefined | null, + values: string[], + operator: string, +): boolean => { + if (values.length === 0) return true + const v = (item ?? '').toLowerCase() + // Для text-фильтра values — массив из одного элемента + const q = (values[0] ?? '').toLowerCase() + if (!q) return true + switch (operator) { + case 'not_contains': + return !v.includes(q) + case 'starts_with': + return v.startsWith(q) + case 'ends_with': + return v.endsWith(q) + case 'is': + return v === q + default: + return v.includes(q) + } +} -const TARIFF_OPTIONS = [ - { value: ALL, label: 'Все тарифы' }, - { value: 'monthly', label: tariffTypeLabel('monthly') }, - { value: 'daily', label: tariffTypeLabel('daily') }, -] +const matchesNumberGte = ( + item: number | undefined | null, + values: number[], +): boolean => { + if (values.length === 0) return true + const threshold = values[0] + if (threshold == null) return true + return Number(item ?? 0) >= threshold +} -const ON_OFF_OPTIONS = [ - { value: ALL, label: 'Любое' }, - { value: 'on', label: 'Включено' }, - { value: 'off', label: 'Выключено' }, -] +interface ActiveFilter { + field: string + operator: string + values: unknown[] +} -const PRESETS_KEY = 'vps-tracker:vps-filter-presets' +/** + * Применяет фильтры к списку VPS. + * Принимает активные фильтры в формате ReUI Filters (Filter[]) — + * массив объектов { field, operator, values }. + */ +export function applyVpsFilters( + items: Vps[], + filters: VpsFiltersState | ActiveFilter[], +): Vps[] { + // Если передан state — конвертируем в active filters + const activeFilters: ActiveFilter[] = Array.isArray(filters) + ? filters + : stateToActiveFilters(filters) + + const search = activeFilters.find((f) => f.field === 'search')?.values?.[0] as string ?? '' + const searchLower = search.toLowerCase() + + return items.filter((item) => { + if (searchLower) { + const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : '' + const haystack = [ + item.ip, item.dns, item.ipv6, extraIps, item.project, item.purpose, item.os, + ].map((s) => (s ?? '').toLowerCase()).join(' ') + if (!haystack.includes(searchLower)) return false + } + + for (const f of activeFilters) { + if (f.field === 'search') continue + switch (f.field) { + case 'providerId': + if (!matchesAny(item.providerId, f.values as string[])) return false + break + case 'providerAccountId': + if (!matchesAny(item.providerAccountId, f.values as string[])) return false + break + case 'country': + if (!matchesAny((item.country ?? '').trim() || undefined, f.values as string[])) return false + break + case 'city': + if (!matchesAny((item.city ?? '').trim() || undefined, f.values as string[])) return false + break + case 'datacenter': + if (!matchesText(item.datacenter, f.values as string[], f.operator)) return false + break + case 'status': + if (!matchesAny(item.status, f.values as string[])) return false + break + case 'environment': + if (!matchesAny(item.environment, f.values as string[])) return false + break + case 'tariffType': + if (!matchesAny(item.tariffType, f.values as string[])) return false + break + case 'monitoring': { + if (f.values.length === 0) break + const on = (f.values as string[]).includes('on') + const off = (f.values as string[]).includes('off') + const itemOn = Boolean(item.monitoringEnabled) + if (on && itemOn) break + if (off && !itemOn) break + return false + } + case 'backup': { + if (f.values.length === 0) break + const on = (f.values as string[]).includes('on') + const off = (f.values as string[]).includes('off') + const itemOn = Boolean(item.backupEnabled) + if (on && itemOn) break + if (off && !itemOn) break + return false + } + case 'project': { + if (f.values.length === 0) break + const proj = (item.project ?? '').trim() + const wants = f.values as string[] + const wantsNone = wants.includes('__none__') + const wantsNamed = wants.filter((v) => v !== '__none__') + let ok = false + if (wantsNone && !proj) ok = true + if (wantsNamed.length && wantsNamed.includes(proj)) ok = true + if (!ok) return false + break + } + case 'minVcpu': + if (!matchesNumberGte(item.vcpu, f.values as number[])) return false + break + case 'minRamGb': + if (!matchesNumberGte(item.ramGb, f.values as number[])) return false + break + case 'minDiskGb': + if (!matchesNumberGte(item.diskGb, f.values as number[])) return false + break + } + } + return true + }) +} + +/** Конвертация VpsFiltersState → ActiveFilter[] для applyVpsFilters. */ +export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] { + const out: ActiveFilter[] = [] + if (state.search) out.push({ field: 'search', operator: 'contains', values: [state.search] }) + if (state.providerId.length) out.push({ field: 'providerId', operator: 'is_any_of', values: state.providerId }) + if (state.providerAccountId.length) out.push({ field: 'providerAccountId', operator: 'is_any_of', values: state.providerAccountId }) + if (state.country.length) out.push({ field: 'country', operator: 'is_any_of', values: state.country }) + if (state.city.length) out.push({ field: 'city', operator: 'is_any_of', values: state.city }) + if (state.datacenter) out.push({ field: 'datacenter', operator: 'contains', values: [state.datacenter] }) + if (state.status.length) out.push({ field: 'status', operator: 'is_any_of', values: state.status }) + if (state.environment.length) out.push({ field: 'environment', operator: 'is_any_of', values: state.environment }) + if (state.tariffType.length) out.push({ field: 'tariffType', operator: 'is_any_of', values: state.tariffType }) + if (state.monitoring.length) out.push({ field: 'monitoring', operator: 'is_any_of', values: state.monitoring }) + if (state.backup.length) out.push({ field: 'backup', operator: 'is_any_of', values: state.backup }) + if (state.project.length) out.push({ field: 'project', operator: 'is_any_of', values: state.project }) + if (state.minVcpu != null) out.push({ field: 'minVcpu', operator: 'gte', values: [state.minVcpu] }) + if (state.minRamGb != null) out.push({ field: 'minRamGb', operator: 'gte', values: [state.minRamGb] }) + if (state.minDiskGb != null) out.push({ field: 'minDiskGb', operator: 'gte', values: [state.minDiskGb] }) + return out +} + +export function countActiveFilters(filters: VpsFiltersState): number { + let n = 0 + if (filters.search) n++ + n += filters.providerId.length + n += filters.providerAccountId.length + n += filters.country.length + n += filters.city.length + if (filters.datacenter) n++ + n += filters.status.length + n += filters.environment.length + n += filters.tariffType.length + n += filters.monitoring.length + n += filters.backup.length + n += filters.project.length + if (filters.minVcpu != null) n++ + if (filters.minRamGb != null) n++ + if (filters.minDiskGb != null) n++ + return n +} export interface VpsFilterPreset { name: string filters: VpsFiltersState } +const PRESETS_KEY = 'vps-tracker:vps-filter-presets' + export function loadFilterPresets(): VpsFilterPreset[] { try { const raw = localStorage.getItem(PRESETS_KEY) @@ -107,7 +254,7 @@ export function loadFilterPresets(): VpsFilterPreset[] { } } -function saveFilterPresets(presets: VpsFilterPreset[]): void { +export function saveFilterPresets(presets: VpsFilterPreset[]): void { try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)) } catch { @@ -115,356 +262,5 @@ function saveFilterPresets(presets: VpsFilterPreset[]): void { } } -export function applyVpsFilters(items: Vps[], filters: VpsFiltersState): Vps[] { - const search = filters.search.toLowerCase() - const minVcpu = Number(filters.minVcpu || 0) - const minRamGb = Number(filters.minRamGb || 0) - const minDiskGb = Number(filters.minDiskGb || 0) - return items.filter((item) => { - const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : '' - if ( - search && - !item.ip?.toLowerCase().includes(search) && - !item.dns?.toLowerCase().includes(search) && - !item.ipv6?.toLowerCase().includes(search) && - !extraIps.toLowerCase().includes(search) && - !item.project?.toLowerCase().includes(search) && - !item.purpose?.toLowerCase().includes(search) && - !item.os?.toLowerCase().includes(search) - ) - return false - if (filters.providerId && item.providerId !== filters.providerId) return false - if (filters.providerAccountId && item.providerAccountId !== filters.providerAccountId) return false - if (filters.country && !item.country?.toLowerCase().includes(filters.country.toLowerCase())) return false - if (filters.city && !item.city?.toLowerCase().includes(filters.city.toLowerCase())) return false - if (filters.datacenter && !item.datacenter?.toLowerCase().includes(filters.datacenter.toLowerCase())) return false - if (filters.status !== ALL && item.status !== filters.status) return false - if (filters.environment !== ALL && item.environment !== filters.environment) return false - if (filters.tariffType !== ALL && item.tariffType !== filters.tariffType) return false - if (filters.monitoring !== ALL) { - const on = filters.monitoring === 'on' - if (on !== Boolean(item.monitoringEnabled)) return false - } - if (filters.backup !== ALL) { - const on = filters.backup === 'on' - if (on !== Boolean(item.backupEnabled)) return false - } - if (minVcpu && Number(item.vcpu || 0) < minVcpu) return false - if (minRamGb && Number(item.ramGb || 0) < minRamGb) return false - if (minDiskGb && Number(item.diskGb || 0) < minDiskGb) return false - const proj = (item.project || '').trim() - if (filters.project) { - if (filters.project === '__none__' ? proj : proj !== filters.project) return false - } - return true - }) -} - -export function countActiveFilters(filters: VpsFiltersState): number { - let n = 0 - if (filters.search) n++ - if (filters.providerId) n++ - if (filters.providerAccountId) n++ - if (filters.country) n++ - if (filters.city) n++ - if (filters.datacenter) n++ - if (filters.status !== ALL) n++ - if (filters.environment !== ALL) n++ - if (filters.tariffType !== ALL) n++ - if (filters.monitoring !== ALL) n++ - if (filters.backup !== ALL) n++ - if (filters.minVcpu) n++ - if (filters.minRamGb) n++ - if (filters.minDiskGb) n++ - if (filters.project) n++ - return n -} - -interface VpsFiltersProps { - filters: VpsFiltersState - onChange: (next: VpsFiltersState) => void - providers: Provider[] - providerAccounts: ProviderAccount[] - projectNameOptions: string[] - countryOptions: AutoCompleteOption[] - cityOptions: AutoCompleteOption[] - presets: VpsFilterPreset[] - onPresetsChange: (presets: VpsFilterPreset[]) => void -} - -export function VpsFilters({ - filters, - onChange, - providers, - providerAccounts, - projectNameOptions, - countryOptions, - cityOptions, - presets, - onPresetsChange, -}: VpsFiltersProps) { - const update = (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 ( - - -
-
- - update('search', e.target.value)} - className="pl-8" - /> -
- update('status', v ?? ALL)} - options={STATUS_OPTIONS} - triggerClassName="w-44" - /> - - onChange({ ...filters, providerId: v ?? '', providerAccountId: '' }) - } - options={providers.map((p) => ({ value: p.id, label: p.name }))} - triggerClassName="w-44" - /> - update('providerAccountId', v ?? '')} - options={accountOptions.map((a) => ({ value: a.id, label: a.name }))} - triggerClassName="w-44" - /> - update('project', v ?? '')} - options={[ - { value: '__none__', label: 'Без проекта' }, - ...projectNameOptions.map((p) => ({ value: p, label: p })), - ]} - triggerClassName="w-44" - /> - - - - Доп. фильтры - {activeCount > 0 ? {activeCount} : null} - - } - /> - - - {hasFilters ? ( - - ) : null} - - {presets.length > 0 ? ( - - Пресеты ({presets.length})} - /> - - {presets.map((p) => ( - applyPreset(p)} - className="justify-between" - > - {p.name} - { - e.stopPropagation() - deletePreset(p.name) - }} - /> - - ))} - - - ) : null} -
- -
-
- - update('country', v)} - options={countryOptions} - searchPlaceholder="Поиск страны…" - emptyText="Нет вариантов" - className="w-36" - /> -
-
- - update('city', v)} - options={cityOptions} - searchPlaceholder="Поиск города…" - emptyText="Нет вариантов" - showLeadingInInput={false} - className="w-36" - /> -
-
- - update('datacenter', e.target.value)} - className="w-40" - /> -
-
- - update('environment', v ?? ALL)} - options={ENV_OPTIONS} - triggerClassName="w-44" - /> -
-
- - update('tariffType', v ?? ALL)} - options={TARIFF_OPTIONS} - triggerClassName="w-36" - /> -
-
- - update('monitoring', v ?? ALL)} - options={ON_OFF_OPTIONS} - triggerClassName="w-36" - /> -
-
- - update('backup', v ?? ALL)} - options={ON_OFF_OPTIONS} - triggerClassName="w-36" - /> -
-
- - update('minVcpu', e.target.value)} - className="w-20" - /> -
-
- - update('minRamGb', e.target.value)} - className="w-20" - /> -
-
- - update('minDiskGb', e.target.value)} - className="w-20" - /> -
- - -
-
-
- ) -} +// Обратная совместимость — для старого кода, который мог импортировать arrOr +export { arrOr } diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/apps/web/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(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 +} diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 8a06a94..20941c6 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -1,8 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState, useMemo } from 'react' +import { Controller } from 'react-hook-form' import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react' import { toast } from 'sonner' +import { format, parseISO, isValid } from 'date-fns' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot' 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 { Textarea } from '@cfdm/ui/components/textarea' import { - VpsFilters, + NumberField, + NumberFieldGroup, + NumberFieldDecrement, + NumberFieldIncrement, + NumberFieldInput, +} from '@/components/reui/number-field' +import { + DateSelector, + type DateSelectorValue, +} from '@/components/reui/date-selector' +import { applyVpsFilters, buildDefaultVpsFilters, - loadFilterPresets, type VpsFiltersState, - type VpsFilterPreset, } from '@/components/vps-filters' +import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar' import type { Vps } from '@/types/entities' import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji, getCountryFlagEmojiByCode } from '@/lib/format' @@ -56,7 +67,6 @@ function VpsPage() { const [editingId, setEditingId] = useState(null) const [defaultValues, setDefaultValues] = useState(EMPTY_FORM) const [filters, setFilters] = useState(buildDefaultVpsFilters()) - const [presets, setPresets] = useState(() => loadFilterPresets()) const settings = snapshot?.settings[0] const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl)) @@ -154,6 +164,7 @@ function VpsPage() { return { value: name, label: name, + code: ref?.code, leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name), } }) @@ -166,9 +177,9 @@ function VpsPage() { const c = (v.city || '').trim() if (c) names.add(c) } - // Из стандартизированного справочника (фильтр по выбранной стране, если есть) - const countryCode = filters.country - ? COUNTRY_BY_NAME_RU[filters.country.toLowerCase()]?.code + // Из стандартизированного справочника (фильтр по первой выбранной стране, если есть) + const countryCode = filters.country[0] + ? COUNTRY_BY_NAME_RU[filters.country[0].toLowerCase()]?.code : undefined for (const city of listCities(countryCode)) names.add(city.name) return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({ @@ -311,16 +322,15 @@ function VpsPage() { > {(snap) => (
- {tableSections.map((section) => (
- + ( + field.onChange(val ?? 0)} + > + + + + + + + )} + /> - + ( + field.onChange(val ?? 0)} + > + + + + + + + )} + /> - + ( + field.onChange(val ?? 0)} + > + + + + + + + )} + />
@@ -449,14 +513,73 @@ function VpsPage() { - + ( + field.onChange(val ?? 0)} + > + + + + + + + )} + /> - + ( + field.onChange(val ?? 0)} + > + + + + + + + )} + />
- + { + 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 ( + { + const d = v.startDate + field.onChange(d ? format(d, 'yyyy-MM-dd') : '') + }} + allowRange={false} + defaultPeriodType="day" + showInput + /> + ) + }} + />