"use client"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ListFilter, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; export type TableFilterOption = { value: string; label: string; }; /** One select filter in the toolbar (or filter modal when there are many). */ export type TableFilterDef = { id: string; label: string; value: string; onChange: (value: string) => void; options: TableFilterOption[]; /** Shown as the “all / any” option. Defaults to translated common.all. */ allLabel?: string; placeholder?: string; }; type TableToolbarProps = { search: string; onSearchChange: (value: string) => void; searchPlaceholder?: string; /** * Preferred multi-filter API. When `filters.length > inlineFilterLimit` (default 3), * filters open in a modal with draft state + Apply / Reset. */ filters?: TableFilterDef[]; /** Legacy single-filter API — still supported (treated as one filter). */ filterValue?: string; onFilterChange?: (value: string) => void; filterOptions?: TableFilterOption[]; filterPlaceholder?: string; filterAllLabel?: string; /** Switch to modal when filter count exceeds this. Default 3. */ inlineFilterLimit?: number; actions?: ReactNode; className?: string; resultCount?: number; resultLabel?: string; }; const DEFAULT_INLINE_LIMIT = 3; function FilterSelect({ filter, value, onValueChange, className, }: { filter: TableFilterDef; value: string; onValueChange: (value: string) => void; className?: string; }) { return ( ); } function countActiveFilters(filters: TableFilterDef[], values: Record) { return filters.reduce((n, f) => { const v = values[f.id] ?? f.value; return n + (v && v !== "all" ? 1 : 0); }, 0); } function valuesFromFilters(filters: TableFilterDef[]): Record { return Object.fromEntries(filters.map((f) => [f.id, f.value || "all"])); } function allCleared(filters: TableFilterDef[]): Record { return Object.fromEntries(filters.map((f) => [f.id, "all"])); } function FilterModal({ open, onOpenChange, filters, }: { open: boolean; onOpenChange: (open: boolean) => void; filters: TableFilterDef[]; }) { const t = useTranslations("filters"); const tCommon = useTranslations("common"); const [draft, setDraft] = useState>(() => valuesFromFilters(filters), ); // Sync draft from applied values only when the modal opens (not on every parent re-render). useEffect(() => { if (open) setDraft(valuesFromFilters(filters)); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional open-only sync }, [open]); function setDraftValue(id: string, value: string) { setDraft((prev) => ({ ...prev, [id]: value })); } function handleApply() { for (const f of filters) { const next = draft[f.id] ?? "all"; if (next !== f.value) f.onChange(next); } onOpenChange(false); } function handleReset() { setDraft(allCleared(filters)); } return ( {t("title")} {t("title")}
{filters.map((filter) => (
setDraftValue(filter.id, v)} className="sm:w-full" />
))}
); } /** In-panel search + optional filters (inline ≤3, modal when more). */ export function TableToolbar({ search, onSearchChange, searchPlaceholder = "Search…", filters: filtersProp, filterValue, onFilterChange, filterOptions, filterPlaceholder = "Filter", filterAllLabel, inlineFilterLimit = DEFAULT_INLINE_LIMIT, actions, className, resultCount, resultLabel, }: TableToolbarProps) { const tCommon = useTranslations("common"); const tFilters = useTranslations("filters"); const [modalOpen, setModalOpen] = useState(false); const filters = useMemo((): TableFilterDef[] => { if (filtersProp?.length) return filtersProp; if (filterOptions?.length && onFilterChange) { return [ { id: "legacy", label: filterPlaceholder, value: filterValue ?? "all", onChange: onFilterChange, options: filterOptions, allLabel: filterAllLabel ?? tCommon("all"), placeholder: filterPlaceholder, }, ]; } return []; }, [ filtersProp, filterOptions, onFilterChange, filterValue, filterPlaceholder, filterAllLabel, tCommon, ]); const useModal = filters.length > inlineFilterLimit; const appliedValues = useMemo(() => valuesFromFilters(filters), [filters]); const activeCount = countActiveFilters(filters, appliedValues); return (
onSearchChange(e.target.value)} placeholder={searchPlaceholder} className="rounded-xl border-border bg-background pl-9 shadow-none" aria-label={searchPlaceholder} />
{useModal ? ( <> ) : ( filters.map((filter) => ( )) )} {typeof resultCount === "number" ? (

{resultCount} {resultLabel ? ` ${resultLabel}` : ""}

) : null}
{actions ? (
{actions}
) : null}
); } export function matchesSearch(haystacks: Array, query: string) { const q = query.trim().toLowerCase(); if (!q) return true; return haystacks.some((h) => String(h ?? "").toLowerCase().includes(q)); }