"use client"; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, Search, SlidersHorizontal, X, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState, useTransition } from "react"; 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 { useDebounce } from "@/hooks/use-debounce"; import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers"; import type { DataTableColumn, PaginatedResult } from "@/types"; /** * 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 = "Search...", bulkActions, onBulkAction, exportUrl, presets, }: DataTableProps) { const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); const [selectedIds, setSelectedIds] = useState>( new Set(), ); const [searchValue, setSearchValue] = useState( searchParams.get("search") || "", ); const [hiddenColumns, setHiddenColumns] = useState>(new Set()); const currentSort = searchParams.get("sort") || ""; const currentOrder = searchParams.get("order") || "desc"; const debouncedSearch = useDebounce(searchValue, 300); const isFirstRender = useRef(true); // 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()}`); }); } // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } updateParams({ search: debouncedSearch, page: "1" }); }, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps function handleSearch(value: string) { setSearchValue(value); } function handleSort(key: string) { const newOrder = currentSort === key && currentOrder === "asc" ? "desc" : "asc"; updateParams({ sort: key, order: newOrder }); } 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) return; await onBulkAction(action, Array.from(selectedIds)); setSelectedIds(new Set()); router.refresh(); } 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)} className="pl-9" />
{/* Column Filters */} {filterColumns.length > 0 && (

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 */}

Toggle 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 && (
{selectedIds.size} selected {bulkActions.map((action) => ( ))}
)}
{data.total} results
{/* Table */}
{bulkActions && ( 0 } 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" > No results found. ) : ( 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 && (

Page {data.page} of {data.lastPage}

{Array.from({ length: Math.min(data.lastPage, 5) }, (_, i) => { let pageNum: number; if (data.lastPage <= 7) { pageNum = i + 1; } else if (data.page <= 4) { pageNum = i + 1; } else if (data.page >= data.lastPage - 3) { pageNum = data.lastPage - 6 + i; } else { pageNum = data.page - 3 + i; } return ( ); })}
)}
); }