"use client"; import { ColumnDef, flexRender, getCoreRowModel, useReactTable, getPaginationRowModel, SortingState, getSortedRowModel, ColumnFiltersState, getFilteredRowModel, VisibilityState, PaginationState } from "@tanstack/react-table"; // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown export type ExtendedColumnDef = ColumnDef< TData, TValue > & { friendlyName?: string; }; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@app/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; import { useTranslations } from "next-intl"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; const STORAGE_KEYS = { PAGE_SIZE: "datatable-page-size", COLUMN_VISIBILITY: "datatable-column-visibility", getTablePageSize: (tableId?: string) => tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE, getTableColumnVisibility: (tableId?: string) => tableId ? `${tableId}-column-visibility` : STORAGE_KEYS.COLUMN_VISIBILITY }; const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { if (typeof window === "undefined") return defaultSize; try { const key = STORAGE_KEYS.getTablePageSize(tableId); const stored = localStorage.getItem(key); if (stored) { const parsed = parseInt(stored, 10); // Validate that it's a reasonable page size if (parsed > 0 && parsed <= 1000) { return parsed; } } } catch (error) { console.warn("Failed to read page size from localStorage:", error); } return defaultSize; }; const setStoredPageSize = (pageSize: number, tableId?: string): void => { if (typeof window === "undefined") return; try { const key = STORAGE_KEYS.getTablePageSize(tableId); localStorage.setItem(key, pageSize.toString()); } catch (error) { console.warn("Failed to save page size to localStorage:", error); } }; const getStoredColumnVisibility = ( tableId?: string, defaultVisibility?: Record ): Record => { if (typeof window === "undefined") return defaultVisibility || {}; try { const key = STORAGE_KEYS.getTableColumnVisibility(tableId); const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); // Validate that it's an object if (typeof parsed === "object" && parsed !== null) { return parsed; } } } catch (error) { console.warn( "Failed to read column visibility from localStorage:", error ); } return defaultVisibility || {}; }; const setStoredColumnVisibility = ( visibility: Record, tableId?: string ): void => { if (typeof window === "undefined") return; try { const key = STORAGE_KEYS.getTableColumnVisibility(tableId); localStorage.setItem(key, JSON.stringify(visibility)); } catch (error) { console.warn( "Failed to save column visibility to localStorage:", error ); } }; type TabFilter = { id: string; label: string; filterFn: (row: any) => boolean; }; type FilterOption = { id: string; label: string; value: string | number | boolean; }; type DataTableFilter = { id: string; label: string; options: FilterOption[]; multiSelect?: boolean; filterFn: ( row: any, selectedValues: (string | number | boolean)[] ) => boolean; defaultValues?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; export type DataTablePaginationState = PaginationState & { pageCount: number; }; export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; title?: string; addButtonText?: string; onAdd?: () => void; onRefresh?: () => void; isRefreshing?: boolean; searchPlaceholder?: string; searchColumn?: string; defaultSort?: { id: string; desc: boolean; }; tabs?: TabFilter[]; defaultTab?: string; filters?: DataTableFilter[]; filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) persistPageSize?: boolean | string; defaultPageSize?: number; columnVisibility?: Record; enableColumnVisibility?: boolean; manualFiltering?: boolean; onSearch?: (input: string) => void; searchQuery?: string; pagination?: DataTablePaginationState; onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") }; export function DataTable({ columns, data, title, addButtonText, onAdd, onRefresh, isRefreshing, searchPlaceholder = "Search...", searchColumn = "name", defaultSort, tabs, defaultTab, filters, filterDisplayMode = "label", persistPageSize = false, defaultPageSize = 20, columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, persistColumnVisibility = false, manualFiltering = false, pagination: paginationState, stickyLeftColumn, onSearch, searchQuery, onPaginationChange, stickyRightColumn }: DataTableProps) { const t = useTranslations(); // Determine table identifier for storage // Use persistPageSize string if provided, otherwise use persistColumnVisibility string, otherwise undefined const tableId = typeof persistPageSize === "string" ? persistPageSize : typeof persistColumnVisibility === "string" ? persistColumnVisibility : undefined; // Auto-enable persistence if column visibility is enabled // Use explicit persistColumnVisibility if provided, otherwise auto-enable when enableColumnVisibility is true and we have a tableId const shouldPersistColumnVisibility = persistColumnVisibility === true || typeof persistColumnVisibility === "string" || (enableColumnVisibility && tableId !== undefined); // Compute initial column visibility (from localStorage if enabled, otherwise from prop/default) const initialColumnVisibility = (() => { if (shouldPersistColumnVisibility) { return getStoredColumnVisibility(tableId, defaultColumnVisibility); } return defaultColumnVisibility || {}; })(); // Initialize page size from storage or default const [pageSize, setPageSize] = useState(() => { if (persistPageSize) { return getStoredPageSize(tableId, defaultPageSize); } return defaultPageSize; }); const [sorting, setSorting] = useState( defaultSort ? [defaultSort] : [] ); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState([]); const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ); const [_pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize }); const pagination = paginationState ?? _pagination; const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); const [activeFilters, setActiveFilters] = useState< Record >(() => { const initial: Record = {}; filters?.forEach((filter) => { initial[filter.id] = filter.defaultValues || []; }); return initial; }); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); const initialColumnVisibilityState = useRef(columnVisibility); const hasUserChangedPageSize = useRef(false); const hasUserChangedColumnVisibility = useRef(false); // Apply tab and custom filters to data const filteredData = useMemo(() => { let result = data; // Apply tab filter if (tabs && activeTab !== "") { const activeTabFilter = tabs.find((tab) => tab.id === activeTab); if (activeTabFilter) { result = result.filter(activeTabFilter.filterFn); } } // Apply custom filters if (filters && filters.length > 0) { filters.forEach((filter) => { const selectedValues = activeFilters[filter.id] || []; if (selectedValues.length > 0) { result = result.filter((row) => filter.filterFn(row, selectedValues) ); } }); } return result; }, [data, tabs, activeTab, filters, activeFilters]); const table = useReactTable({ data: filteredData, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, onColumnVisibilityChange: setColumnVisibility, onPaginationChange: onPaginationChange ? (state) => { const newState = typeof state === "function" ? state(pagination) : state; onPaginationChange(newState); } : setPagination, manualFiltering, manualPagination: Boolean(paginationState), pageCount: paginationState?.pageCount, initialState: { pagination, columnVisibility: initialColumnVisibility }, state: { sorting, columnFilters, globalFilter, columnVisibility, pagination } }); // Persist pageSize to localStorage when it changes (but not on initial mount) useEffect(() => { if (persistPageSize && pagination.pageSize !== pageSize) { // Only store if user has actually changed it from initial value if ( hasUserChangedPageSize.current && pagination.pageSize !== initialPageSize.current ) { setStoredPageSize(pagination.pageSize, tableId); } setPageSize(pagination.pageSize); } }, [pagination.pageSize, persistPageSize, tableId, pageSize]); useEffect(() => { // Persist column visibility to localStorage when it changes (but not on initial mount) if (shouldPersistColumnVisibility) { const hasChanged = JSON.stringify(columnVisibility) !== JSON.stringify(initialColumnVisibilityState.current); if (hasChanged) { // Mark as user-initiated change and persist hasUserChangedColumnVisibility.current = true; setStoredColumnVisibility(columnVisibility, tableId); } } }, [columnVisibility, shouldPersistColumnVisibility, tableId]); const handleTabChange = (value: string) => { setActiveTab(value); // Reset to first page when changing tabs setPagination((prev) => ({ ...prev, pageIndex: 0 })); }; const handleFilterChange = ( filterId: string, optionValue: string | number | boolean, checked: boolean ) => { setActiveFilters((prev) => { const currentValues = prev[filterId] || []; const filter = filters?.find((f) => f.id === filterId); if (!filter) return prev; let newValues: (string | number | boolean)[]; if (filter.multiSelect) { // Multi-select: add or remove the value if (checked) { newValues = [...currentValues, optionValue]; } else { newValues = currentValues.filter((v) => v !== optionValue); } } else { // Single-select: replace the value newValues = checked ? [optionValue] : []; } return { ...prev, [filterId]: newValues }; }); // Reset to first page when changing filters setPagination((prev) => ({ ...prev, pageIndex: 0 })); }; // Calculate display text for a filter based on selected values const getFilterDisplayText = (filter: DataTableFilter): string => { const selectedValues = activeFilters[filter.id] || []; if (selectedValues.length === 0) { return filter.label; } const selectedOptions = filter.options.filter((option) => selectedValues.includes(option.value) ); if (selectedOptions.length === 0) { return filter.label; } if (selectedOptions.length === 1) { return selectedOptions[0].label; } // Multiple selections: always join with "and" return selectedOptions.map((opt) => opt.label).join(" and "); }; // Enhanced pagination component that updates our local state const handlePageSizeChange = (newPageSize: number) => { hasUserChangedPageSize.current = true; setPagination((prev) => ({ ...prev, pageSize: newPageSize, pageIndex: 0 })); setPageSize(newPageSize); // Persist immediately when user changes it if (persistPageSize) { setStoredPageSize(newPageSize, tableId); } }; // Helper function to check if a column should be sticky const isStickyColumn = ( columnId: string | undefined, accessorKey: string | undefined, position: "left" | "right" ): boolean => { if (position === "left" && stickyLeftColumn) { return ( columnId === stickyLeftColumn || accessorKey === stickyLeftColumn ); } if (position === "right" && stickyRightColumn) { return ( columnId === stickyRightColumn || accessorKey === stickyRightColumn ); } return false; }; // Get sticky column classes const getStickyClasses = ( columnId: string | undefined, accessorKey: string | undefined ): string => { if (isStickyColumn(columnId, accessorKey, "left")) { return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]"; } if (isStickyColumn(columnId, accessorKey, "right")) { return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]"; } return ""; }; return (
{ onSearch ? onSearch(e.currentTarget.value) : table.setGlobalFilter( String(e.target.value) ); }} className="w-full pl-8" />
{filters && filters.length > 0 && (
{filters.map((filter) => { const selectedValues = activeFilters[filter.id] || []; const hasActiveFilters = selectedValues.length > 0; const displayMode = filter.displayMode || filterDisplayMode; const displayText = displayMode === "calculated" ? getFilterDisplayText(filter) : filter.label; return ( {filter.label} {filter.options.map( (option) => { const isChecked = selectedValues.includes( option.value ); return ( handleFilterChange( filter.id, option.value, checked ) } onSelect={(e) => e.preventDefault() } > {option.label} ); } )} ); })}
)} {tabs && tabs.length > 0 && ( {tabs.map((tab) => ( {tab.label} ( {data.filter(tab.filterFn).length}) ))} )}
{onRefresh && (
)} {onAdd && addButtonText && (
)}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const columnId = header.column.id; const accessorKey = ( header.column.columnDef as any ).accessorKey as string | undefined; const stickyClasses = getStickyClasses( columnId, accessorKey ); const isRightSticky = isStickyColumn( columnId, accessorKey, "right" ); const hasHideableColumns = enableColumnVisibility && table .getAllColumns() .some((col) => col.getCanHide() ); return ( {header.isPlaceholder ? null : isRightSticky && hasHideableColumns ? (
{t( "toggleColumns" ) || "Toggle columns"} {table .getAllColumns() .filter( ( column ) => column.getCanHide() ) .map( ( column ) => { const columnDef = column.columnDef as any; const friendlyName = columnDef.friendlyName; const displayName = friendlyName || (typeof columnDef.header === "string" ? columnDef.header : column.id); return ( column.toggleVisibility( !!value ) } onSelect={( e ) => e.preventDefault() } > { displayName } ); } )}
{flexRender( header .column .columnDef .header, header.getContext() )}
) : ( flexRender( header.column .columnDef .header, header.getContext() ) )}
); })}
))}
{table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row .getVisibleCells() .map((cell) => { const columnId = cell.column.id; const accessorKey = ( cell.column .columnDef as any ).accessorKey as | string | undefined; const stickyClasses = getStickyClasses( columnId, accessorKey ); const isRightSticky = isStickyColumn( columnId, accessorKey, "right" ); return ( {flexRender( cell.column .columnDef .cell, cell.getContext() )} ); })} )) ) : ( No results found. )}
{table.getRowModel().rows?.length > 0 && ( )}
); }