"use client"; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, Search, SlidersHorizontal, X, } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers"; import { tablePageNumbers } from "@/lib/admin-table-state"; import type { DataTableColumn, PaginatedResult } from "@/types/common"; /** * One-click filter preset. The chip applies/clears the listed query-string * keys at once; the preset is "active" when every key matches the URL state. */ export interface DataTablePreset { label: string; params: Record; } interface DataTableProps { data: PaginatedResult; columns: DataTableColumn[]; searchPlaceholder?: string; bulkActions?: { label: string; value: string; variant?: "destructive" | "default"; }[]; onBulkAction?: (action: string, ids: (number | string)[]) => Promise; exportUrl?: string; presets?: DataTablePreset[]; } export function DataTable({ data, columns, searchPlaceholder, bulkActions, onBulkAction, exportUrl, presets, }: DataTableProps) { const t = useTranslations("pages.admin.table"); const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); const scope = `${pathname}?${searchParams.toString()}:${data.rows.map((row) => row.id).join(",")}`; const [selection, setSelection] = useState<{ scope: string; ids: Set; }>({ scope, ids: new Set() }); const selectedIds = selection.scope === scope ? selection.ids : new Set(); function setSelectedIds( value: | Set | ((prev: Set) => Set), ) { setSelection({ scope, ids: typeof value === "function" ? value(selectedIds) : value, }); } const urlSearch = searchParams.get("search") ?? ""; const [draft, setDraft] = useState({ base: urlSearch, value: urlSearch }); if (draft.base !== urlSearch) setDraft({ base: urlSearch, value: urlSearch }); const searchValue = draft.base === urlSearch ? draft.value : urlSearch; const [bulkBusy, setBulkBusy] = useState(false); const bulkLock = useRef(false); const [hiddenColumns, setHiddenColumns] = useState>(new Set()); const currentSort = searchParams.get("sort") || ""; const currentOrder = searchParams.get("order") || "desc"; // Collect active filters from URL const filterColumns = columns.filter( (col) => col.filterOptions && col.filterOptions.length > 0, ); const activeFilters: Record = {}; for (const col of filterColumns) { const key = col.filterKey || `filter_${col.key}`; const val = searchParams.get(key); if (val) activeFilters[key] = val; } const activeFilterCount = Object.keys(activeFilters).length; function updateParams(updates: Record) { const params = new URLSearchParams(searchParams.toString()); for (const [key, value] of Object.entries(updates)) { if (value) params.set(key, value); else params.delete(key); } startTransition(() => { router.push(`?${params.toString()}`, { scroll: false }); }); } useEffect(() => { if (searchValue === urlSearch) return; const timer = setTimeout(() => { const params = new URLSearchParams(searchParams.toString()); if (searchValue) params.set("search", searchValue); else params.delete("search"); params.set("page", "1"); startTransition(() => router.replace(`?${params.toString()}`, { scroll: false }), ); }, 300); return () => clearTimeout(timer); }, [searchValue, urlSearch, searchParams, router]); function handleSearch(value: string) { setDraft({ base: urlSearch, value }); } function handleSort(key: string) { const newOrder = currentSort === key && currentOrder === "asc" ? "desc" : "asc"; updateParams({ sort: key, order: newOrder, page: "1" }); } function handlePageChange(page: number) { updateParams({ page: String(page) }); } function handlePerPageChange(perPage: string) { updateParams({ perPage, page: "1" }); } function handleFilterChange(col: DataTableColumn, value: string) { const key = col.filterKey || `filter_${col.key}`; updateParams({ [key]: value === "__all__" ? "" : value, page: "1" }); } function clearAllFilters() { const updates: Record = { page: "1" }; for (const col of filterColumns) { const key = col.filterKey || `filter_${col.key}`; updates[key] = ""; } updateParams(updates); } function toggleSelectAll() { if (selectedIds.size === data.rows.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(data.rows.map((r) => r.id))); } } function toggleSelect(id: number | string) { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function handleBulkAction(action: string) { if ( selectedIds.size === 0 || !onBulkAction || bulkLock.current || isPending ) return; const definition = bulkActions?.find((item) => item.value === action); if ( definition?.variant === "destructive" && !window.confirm( t("confirmBulk", { action: definition.label, count: selectedIds.size }), ) ) return; bulkLock.current = true; setBulkBusy(true); try { await onBulkAction(action, Array.from(selectedIds)); setSelectedIds(new Set()); router.refresh(); } catch { toast.error(t("bulkError")); } finally { bulkLock.current = false; setBulkBusy(false); } } function getSortIcon(key: string) { if (currentSort !== key) return ; return currentOrder === "asc" ? ( ) : ( ); } function isPresetActive(preset: DataTablePreset): boolean { return Object.entries(preset.params).every( ([k, v]) => (searchParams.get(k) ?? "") === v, ); } function applyPreset(preset: DataTablePreset) { // Apply (or toggle off) the preset by overwriting only its keys + reset page. const updates: Record = { page: "1" }; if (isPresetActive(preset)) { for (const k of Object.keys(preset.params)) updates[k] = ""; } else { Object.assign(updates, preset.params); } updateParams(updates); } return (
{/* Preset filter chips */} {presets && presets.length > 0 && (
{presets.map((preset) => { const active = isPresetActive(preset); return ( ); })}
)} {/* Toolbar */}
handleSearch(e.target.value)} style={{ paddingLeft: 36 }} className="pl-9" />
{/* Column Filters */} {filterColumns.length > 0 && (

{t("filters")}

{activeFilterCount > 0 && ( )}
{filterColumns.map((col) => { const key = col.filterKey || `filter_${col.key}`; const currentValue = searchParams.get(key) || "__all__"; return (
{col.label}
); })}
)} {/* Active Filter Badges */} {activeFilterCount > 0 && (
{filterColumns.map((col) => { const key = col.filterKey || `filter_${col.key}`; const val = searchParams.get(key); if (!val) return null; const opt = col.filterOptions?.find((o) => o.value === val); return ( {col.label}: {opt?.label || val} ); })}
)} {/* Column Visibility */}

{t("columns")}

{columns.map((col) => (
{ setHiddenColumns((prev) => { const next = new Set(prev); if (checked) next.delete(col.key); else next.add(col.key); return next; }); }} /> {col.label}
))}
{exportUrl && ( )} {bulkActions && selectedIds.size > 0 && (
{t("selected", { count: selectedIds.size })} {bulkActions.map((action) => ( ))}
)}
{t("results", { count: data.total })}
{/* Table */}
{bulkActions && ( 0 } aria-label={t("selectAll")} disabled={isPending || bulkBusy} onCheckedChange={toggleSelectAll} /> )} {columns .filter((col) => !hiddenColumns.has(col.key)) .map((col) => ( {col.sortable ? ( ) : ( col.label )} ))} {data.rows.length === 0 ? ( !hiddenColumns.has(c.key)).length + (bulkActions ? 1 : 0) } className="h-24 text-center text-muted-foreground" > {t("empty")} ) : ( data.rows.map((row) => ( {bulkActions && ( toggleSelect(row.id)} /> )} {columns .filter((col) => !hiddenColumns.has(col.key)) .map((col) => ( {col.render ? col.render( (row as Record)[ col.key ] as T[keyof T], row, ) : String( (row as Record)[col.key] ?? "", )} ))} )) )}
{/* Pagination */} {data.lastPage > 1 && (

{t("pageOf", { page: data.page, last: data.lastPage })}

{tablePageNumbers(data.page, data.lastPage).map((pageNum) => { return ( ); })}
)}
); }