'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, 7) }, (_, 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 ( ) })}
)}
) }