'use client'; import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import Link from 'next/link'; import { HugeiconsIcon } from '@hugeicons/react'; import { ArrowDataTransferVerticalIcon, ArrowUp01Icon, ArrowDown01Icon, ChevronRightIcon, } from '@hugeicons/core-free-icons'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import { RowActionButton, RowActions } from '@/components/shared/row-actions'; import { TableToolbar } from './table-toolbar'; import { TablePagination } from './table-pagination'; import { filterData } from './hooks/use-table-filter'; import { sortData } from './hooks/use-table-sort'; import { exportTable } from './hooks/use-table-export'; import { getNestedValue, resolveUrlTemplate } from './utils/nested-value'; import type { TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState, ServerSideParams, ExportFormat, RowActionItem, } from './types'; interface DataTableProps { config: TableConfig; data: T[]; loading?: boolean; totalCount?: number; onSortChange?: (s: SortState) => void; onFilterChange?: (f: FilterState) => void; onPageChange?: (p: PageState) => void; onSelectionChange?: (rows: T[]) => void; onRowClick?: (row: T) => void; onRowExpand?: (row: T) => void; onServerSideChange?: (p: ServerSideParams) => void; } function normalizeField(f: FieldConfig | string): FieldConfig { return typeof f === 'string' ? { key: f } : f; } function formatTextValue(raw: unknown): string { if (raw == null) return ''; if (typeof raw === 'string') return raw; if (typeof raw === 'number' || typeof raw === 'bigint') return String(raw); if (typeof raw === 'boolean') return raw ? 'Yes' : 'No'; if (Array.isArray(raw)) { if (raw.length === 0) return ''; if (raw.every(item => item == null || ['string', 'number', 'bigint', 'boolean'].includes(typeof item))) { return raw.map(item => formatTextValue(item)).filter(Boolean).join(', '); } return `${raw.length} item${raw.length === 1 ? '' : 's'}`; } if (typeof raw === 'object') { const record = raw as Record; const namedValue = record.name ?? record.title ?? record.label ?? record.id; return namedValue == null ? '' : formatTextValue(namedValue); } return String(raw); } function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) { if (sortState.column !== colKey) { return ( ); } return sortState.direction === 'asc' ? : ; } function DataTableRowActions({ row, actions, }: { row: T; actions: RowActionItem[]; }) { const visibleActions = actions.filter(action => action.visible ? action.visible(row) : true); if (visibleActions.length === 0) return null; return ( {visibleActions.map((action) => ( { e.stopPropagation(); action.onClick(row); }} /> ))} ); } function MobileCard({ row, columns, mobileCardCfg, onClick, }: { row: T; columns: ColumnDef[]; mobileCardCfg?: TableConfig['mobileCard']; onClick?: (row: T) => void; }) { const actionCol = columns.find(c => c.key === '__row_actions' || (!c.title && c.cellRender)); const dataColumns = columns.filter(c => !!c.title); const primaryCol = dataColumns[0]; // Always 2 columns, max 4 fields (2 rows) to prevent cramping const previewCols = (mobileCardCfg?.previewColumns ? dataColumns.filter(c => mobileCardCfg.previewColumns!.includes(c.key)) : dataColumns.slice(1, 3) ).slice(0, 4); const href = mobileCardCfg?.detailHref?.(row); const isNavigable = !!href || !!onClick; const inner = (
{/* Header: primary field + chevron */}
{primaryCol && }
{isNavigable && ( )}
{/* Preview fields — fixed 2-column grid */} {previewCols.length > 0 && (
{previewCols.map(col => (

{col.title}

))}
)} {/* Actions — only when not navigating away */} {!href && actionCol && (
e.stopPropagation()} > {actionCol.cellRender!(row, actionCol)}
)}
); if (href) { return {inner}; } return (
onClick?.(row)}> {inner}
); } function CellContent({ row, col, }: { row: T; col: ColumnDef }) { if (col.cellRender) return <>{col.cellRender(row, col)}; const fields = col.fields.map(normalizeField); const layout = col.layout ?? 'vertical'; if (fields.length === 1 && (fields[0].renderer ?? 'text') === 'text') { const field = fields[0]; const raw = getNestedValue(row, field.key); const text = field.transform ? field.transform(raw, row) : `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`; return (
{text || '—'}
); } const rendered = fields.map((field, i) => { const raw = getNestedValue(row, field.key); const text = field.transform ? field.transform(raw, row) : `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`; const renderer = field.renderer ?? 'text'; if (renderer === 'badge') { const colorClass = field.badgeConfig?.colorMap?.[String(raw)] ?? field.badgeConfig?.defaultColor ?? ''; return ( {text} ); } if (renderer === 'link') { const href = field.linkConfig?.urlKey ? String(getNestedValue(row, field.linkConfig.urlKey) ?? '#') : field.linkConfig?.urlTemplate ? resolveUrlTemplate(field.linkConfig.urlTemplate, row) : '#'; return ( e.stopPropagation()} > {text} ); } return (
{text}
); }); const crossAlign = col.align === 'right' ? 'items-end' : col.align === 'center' ? 'items-center' : ''; if (layout === 'horizontal') { return (
{rendered}
); } return
{rendered}
; } export function DataTable({ config, data, loading, totalCount, onSortChange, onFilterChange, onPageChange, onSelectionChange, onRowClick, onServerSideChange, }: DataTableProps) { const safeData = useMemo(() => (Array.isArray(data) ? data : []), [data]); const [sortState, setSortState] = useState({ column: null, direction: null }); const [filterState, setFilterState] = useState({ global: '', columns: {} }); const [pageState, setPageState] = useState({ page: 1, pageSize: config.pagination?.pageSize ?? 10, }); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [expandedKeys, setExpandedKeys] = useState>(new Set()); const [hiddenColumns, setHiddenColumns] = useState>( () => new Set(config.columns.filter(c => c.visible === false).map(c => c.key)) ); const [columnWidths, setColumnWidths] = useState>({}); const [columnOrder, setColumnOrder] = useState(() => config.columns.map(c => c.key)); const [draggedKey, setDraggedKey] = useState(null); const [dropTargetKey, setDropTargetKey] = useState(null); const scrollContainerRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [containerH, setContainerH] = useState(500); function rowKey(row: T): string { if (!config.rowKey) return JSON.stringify(row); if (typeof config.rowKey === 'function') return String(config.rowKey(row)); return String(row[config.rowKey as keyof T]); } const normalizedColumns = useMemo(() => { const baseColumns = config.columns.map(col => ({ ...col, fields: col.fields.map(normalizeField) as FieldConfig[], })); if (!config.rowActions) { return baseColumns; } const actionsColumn: ColumnDef = { key: '__row_actions', title: config.rowActions.title ?? 'Action', fields: [], width: config.rowActions.width ?? 96, minWidth: config.rowActions.minWidth ?? 96, resizable: false, hideable: false, align: config.rowActions.align ?? 'right', exportable: false, cellRender: (row: T) => ( ), }; return [ ...baseColumns, actionsColumn, ]; }, [config.columns, config.rowActions]); useEffect(() => { setColumnOrder((prev) => { const nextKeys = normalizedColumns.map((col) => col.key); const kept = prev.filter((key) => nextKeys.includes(key)); const missing = nextKeys.filter((key) => !kept.includes(key)); const next = [...kept, ...missing]; if (next.length === prev.length && next.every((key, index) => key === prev[index])) { return prev; } return next; }); }, [normalizedColumns]); const orderedColumns = useMemo(() => columnOrder .map(key => normalizedColumns.find(c => c.key === key)) .filter(Boolean) as typeof normalizedColumns, [normalizedColumns, columnOrder] ); const visibleColumns = useMemo(() => orderedColumns.filter(c => c.hideable === false || !hiddenColumns.has(c.key)), [orderedColumns, hiddenColumns] ); const filteredData = useMemo(() => config.serverSide ? safeData : filterData(safeData, filterState, normalizedColumns as ColumnDef[]), [safeData, filterState, normalizedColumns, config.serverSide] ); const sortedData = useMemo(() => config.serverSide ? safeData : sortData(filteredData, sortState, normalizedColumns as ColumnDef[]), [filteredData, sortState, normalizedColumns, config.serverSide] ); const totalRecords = useMemo(() => config.serverSide ? (totalCount ?? safeData.length) : filteredData.length, [config.serverSide, totalCount, safeData.length, filteredData.length] ); const rowHeightPx = useMemo(() => { const map: Record = { compact: 36, normal: 48, comfortable: 60 }; return map[config.density ?? 'normal'] ?? 48; }, [config.density]); const virtualRange = useMemo(() => { if (!config.virtualScroll) return null; const buf = 6; const start = Math.max(0, Math.floor(scrollTop / rowHeightPx) - buf); const end = Math.min(sortedData.length, Math.ceil((scrollTop + containerH) / rowHeightPx) + buf); return { start, end }; }, [config.virtualScroll, scrollTop, containerH, rowHeightPx, sortedData.length]); const paginatedData = useMemo(() => { if (config.serverSide || !config.pagination?.enabled) return sortedData; const { page, pageSize } = pageState; return sortedData.slice((page - 1) * pageSize, page * pageSize); }, [sortedData, pageState, config.serverSide, config.pagination?.enabled]); const displayData = useMemo(() => virtualRange ? sortedData.slice(virtualRange.start, virtualRange.end) : paginatedData, [virtualRange, sortedData, paginatedData] ); const spacerTop = (virtualRange?.start ?? 0) * rowHeightPx; const spacerBottom = virtualRange ? (sortedData.length - virtualRange.end) * rowHeightPx : 0; const selectablePageData = useMemo(() => config.isRowSelectable ? paginatedData.filter(r => config.isRowSelectable!(r)) : paginatedData, [paginatedData, config.isRowSelectable] ); const allSelected = selectablePageData.length > 0 && selectablePageData.every(r => selectedKeys.has(rowKey(r))); const someSelected = selectablePageData.some(r => selectedKeys.has(rowKey(r))) && !allSelected; // --- Handlers --- function handleSort(col: ColumnDef) { if (!col.sortable) return; setSortState(prev => { const next: SortState = prev.column !== col.key ? { column: col.key, direction: 'asc' } : prev.direction === 'asc' ? { column: col.key, direction: 'desc' } : { column: null, direction: null }; onSortChange?.(next); if (config.serverSide) onServerSideChange?.({ sort: next, filter: filterState, page: pageState }); return next; }); } function handleGlobalFilter(value: string) { const next: FilterState = { ...filterState, global: value }; setFilterState(next); setPageState(p => ({ ...p, page: 1 })); onFilterChange?.(next); if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: next, page: { ...pageState, page: 1 } }); } function toggleRow(row: T) { if (config.isRowSelectable && !config.isRowSelectable(row)) return; const key = rowKey(row); const next = new Set(selectedKeys); next.has(key) ? next.delete(key) : next.add(key); setSelectedKeys(next); onSelectionChange?.(safeData.filter(r => next.has(rowKey(r)))); } function toggleAll() { const next = new Set(selectedKeys); if (allSelected) { selectablePageData.map(rowKey).forEach(k => next.delete(k)); } else { selectablePageData.forEach(r => next.add(rowKey(r))); } setSelectedKeys(next); onSelectionChange?.(safeData.filter(r => next.has(rowKey(r)))); } function clearSelection() { setSelectedKeys(new Set()); onSelectionChange?.([]); } function toggleExpand(row: T) { const key = rowKey(row); setExpandedKeys(prev => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); return next; }); } function toggleColumnVisibility(key: string) { const column = orderedColumns.find(col => col.key === key); if (!column || column.hideable === false) return; setHiddenColumns(prev => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); return next; }); } function handlePageChange(page: number) { const next = { ...pageState, page }; setPageState(next); onPageChange?.(next); if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: filterState, page: next }); } function handlePageSizeChange(pageSize: number) { const next = { page: 1, pageSize }; setPageState(next); onPageChange?.(next); if (config.serverSide) onServerSideChange?.({ sort: sortState, filter: filterState, page: next }); } function handleScroll(e: React.UIEvent) { setScrollTop(e.currentTarget.scrollTop); setContainerH(e.currentTarget.clientHeight); } const handleResizeStart = useCallback((e: React.PointerEvent, colKey: string) => { e.preventDefault(); e.stopPropagation(); const th = (e.currentTarget as HTMLElement).closest('th') as HTMLElement; if (!th) return; const startX = e.clientX; const startWidth = th.offsetWidth; th.setPointerCapture(e.pointerId); const onMove = (ev: PointerEvent) => { const newWidth = Math.max(80, startWidth + ev.clientX - startX); setColumnWidths(w => ({ ...w, [colKey]: newWidth })); }; const onUp = () => { th.removeEventListener('pointermove', onMove); th.removeEventListener('pointerup', onUp); }; th.addEventListener('pointermove', onMove); th.addEventListener('pointerup', onUp); }, []); function handleDragStart(e: React.DragEvent, colKey: string) { setDraggedKey(colKey); e.dataTransfer.effectAllowed = 'move'; } function handleDragOver(e: React.DragEvent, colKey: string) { e.preventDefault(); setDropTargetKey(colKey); } function handleDrop(e: React.DragEvent, colKey: string) { e.preventDefault(); if (!draggedKey || draggedKey === colKey) return; setColumnOrder(prev => { const next = [...prev]; const from = next.indexOf(draggedKey); const to = next.indexOf(colKey); if (from < 0 || to < 0) return prev; next.splice(from, 1); next.splice(to, 0, draggedKey); return next; }); setDraggedKey(null); setDropTargetKey(null); } function handleDragEnd() { setDraggedKey(null); setDropTargetKey(null); } const exportConfig = typeof config.exportable === 'object' ? config.exportable : undefined; const showExport = !!config.exportable; function handleExport(format: ExportFormat) { exportTable(format, sortedData, visibleColumns as ColumnDef[], exportConfig?.filename ?? 'export'); } const skeletonCount = config.pagination?.pageSize ?? 10; const alignClass = (col: ColumnDef) => col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : 'justify-start'; return (
[]} hiddenColumns={hiddenColumns} globalFilter={filterState.global} showExport={showExport} exportFormats={exportConfig?.formats} selectedCount={selectedKeys.size} onGlobalFilterChange={handleGlobalFilter} onColumnVisibilityChange={toggleColumnVisibility} onExport={handleExport} onClearSelection={clearSelection} /> {/* Mobile card view */}
{loading && Array.from({ length: skeletonCount }).map((_, i) => (
{Array.from({ length: 4 }).map((__, j) => )}
))} {!loading && displayData.length === 0 && (
{config.emptyMessage ?? 'No records found.'}
)} {!loading && displayData.map(row => ( []} mobileCardCfg={config.mobileCard} onClick={onRowClick} /> ))}
{/* Desktop table view */}
{config.selectable && ( )} {config.expandable && } {visibleColumns.map(col => { const width = columnWidths[col.key] ?? col.width ?? 160; return ( handleDragStart(e, col.key)} onDragOver={e => handleDragOver(e, col.key)} onDrop={e => handleDrop(e, col.key)} onDragEnd={handleDragEnd} onClick={() => handleSort(col as ColumnDef)} >
))}> {col.headerRender ? col.headerRender(col as ColumnDef) : col.title} {col.sortable && }
{col.resizable !== false && (
handleResizeStart(e, col.key)} onClick={e => e.stopPropagation()} /> )} ); })} {loading && Array.from({ length: skeletonCount }).map((_, i) => ( {config.selectable && } {config.expandable && } {visibleColumns.map(col => ( ))} ))} {!loading && displayData.length === 0 && ( {config.emptyMessage ?? 'No records found.'} )} {!loading && virtualRange && spacerTop > 0 && (
)} {!loading && displayData.map(row => { const key = rowKey(row); const isSelected = selectedKeys.has(key); const isExpanded = expandedKeys.has(key); return ( onRowClick?.(row)} > {config.selectable && ( e.stopPropagation()}> toggleRow(row)} /> )} {config.expandable && ( e.stopPropagation()}> )} {visibleColumns.map(col => ( } /> ))} {config.expandable && isExpanded && ( {config.expandRender?.(row)} )} ); })} {!loading && virtualRange && spacerBottom > 0 && ( )}
{config.pagination?.enabled && ( )}
); }