This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-B2B/src/components/table/table-toolbar.tsx
kirukib 1f30f00da6 Finalize portal polish: profile, avatars, ID cards, and list UX.
Ship leftover Yimaru login/personas, sidebar sections, filter modal, chart/stat clip fixes, and matching feature docs so the polish work is fully committed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 21:57:07 +03:00

331 lines
9.9 KiB
TypeScript

"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 (
<Select value={value || "all"} onValueChange={(v) => onValueChange(v ?? "all")}>
<SelectTrigger
className={cn(
"w-full rounded-xl border-border bg-background shadow-none sm:w-[180px]",
className,
)}
>
<SelectValue placeholder={filter.placeholder ?? filter.label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{filter.allLabel ?? "All"}</SelectItem>
{filter.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function countActiveFilters(filters: TableFilterDef[], values: Record<string, string>) {
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<string, string> {
return Object.fromEntries(filters.map((f) => [f.id, f.value || "all"]));
}
function allCleared(filters: TableFilterDef[]): Record<string, string> {
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<Record<string, string>>(() =>
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 p-0 sm:max-w-md" showCloseButton>
<DialogHeader className="border-b border-border px-4 py-3.5">
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription className="sr-only">{t("title")}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[min(60vh,420px)] flex-col gap-4 overflow-y-auto px-4 py-4">
{filters.map((filter) => (
<div key={filter.id} className="flex flex-col gap-1.5">
<Label htmlFor={`filter-${filter.id}`} className="text-xs font-medium">
{filter.label}
</Label>
<FilterSelect
filter={{
...filter,
allLabel: filter.allLabel ?? tCommon("all"),
}}
value={draft[filter.id] ?? "all"}
onValueChange={(v) => setDraftValue(filter.id, v)}
className="sm:w-full"
/>
</div>
))}
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="ghost" onClick={handleReset}>
{t("reset")}
</Button>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={handleApply}>
{t("apply")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 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 (
<div
className={cn(
"flex flex-col gap-3 border-b border-border bg-card px-4 py-3.5 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between",
className,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-2.5 sm:flex-row sm:items-center">
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="rounded-xl border-border bg-background pl-9 shadow-none"
aria-label={searchPlaceholder}
/>
</div>
{useModal ? (
<>
<Button
type="button"
variant="outline"
className="gap-2 rounded-xl border-border bg-background shadow-none"
onClick={() => setModalOpen(true)}
aria-label={
activeCount > 0
? `${tFilters("title")} (${tFilters("active", { count: activeCount })})`
: tFilters("title")
}
>
<ListFilter className="size-4" />
{tFilters("title")}
{activeCount > 0 ? (
<span
className="inline-flex min-w-5 items-center justify-center rounded-md bg-foreground/10 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-foreground"
title={tFilters("active", { count: activeCount })}
>
{activeCount}
</span>
) : null}
</Button>
<FilterModal
open={modalOpen}
onOpenChange={setModalOpen}
filters={filters}
/>
</>
) : (
filters.map((filter) => (
<FilterSelect
key={filter.id}
filter={{
...filter,
allLabel: filter.allLabel ?? tCommon("all"),
}}
value={filter.value}
onValueChange={filter.onChange}
/>
))
)}
{typeof resultCount === "number" ? (
<p className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
{resultCount}
{resultLabel ? ` ${resultLabel}` : ""}
</p>
) : null}
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</div>
);
}
export function matchesSearch(haystacks: Array<string | number | null | undefined>, query: string) {
const q = query.trim().toLowerCase();
if (!q) return true;
return haystacks.some((h) => String(h ?? "").toLowerCase().includes(q));
}