'use client'; import { ChevronDown, ChevronUp } from 'lucide-react'; import * as React from 'react'; import { cn } from '@/lib/utils'; export type DataTableAlign = 'left' | 'right' | 'center'; export interface DataTableColumn { /** Key duy nhất. */ id: string; /** Header hiển thị. */ header: React.ReactNode; /** Render cell. */ cell: (row: T, index: number) => React.ReactNode; /** Căn lề. */ align?: DataTableAlign; /** Có cho phép sort không. */ sortable?: boolean; /** Function lấy giá trị sort (trả về number | string). */ sortValue?: (row: T) => number | string; /** Rộng cột. */ width?: string; /** Hiển thị dạng mono (tabular-nums). */ numeric?: boolean; } export interface DataTableProps { columns: DataTableColumn[]; data: T[]; /** Hàm trả về key row duy nhất. */ getRowId?: (row: T, index: number) => string | number; /** Click row. */ onRowClick?: (row: T) => void; /** Hover row — fires with null on mouse leave. */ onRowHover?: (row: T | null) => void; /** Hiện sticky header. */ stickyHeader?: boolean; /** Trạng thái loading. */ loading?: boolean; /** Text hiển thị khi rỗng. */ emptyText?: React.ReactNode; className?: string; /** Compact row height. */ dense?: boolean; /** Col sort mặc định. */ defaultSortId?: string; defaultSortDir?: 'asc' | 'desc'; } /** * DataTable ticker-style: * - Row cao 36px (dense mặc định), alternating bg, sticky header. * - Sort client-side qua `sortable` + `sortValue`. * - Số hiển thị font-mono với `column.numeric = true`. * * Giữ nguyên data contract: không tự fetch, component chỉ render. */ export function DataTable({ columns, data, getRowId, onRowClick, onRowHover, stickyHeader = true, loading = false, emptyText = 'Không có dữ liệu', className, dense = true, defaultSortId, defaultSortDir = 'desc', }: DataTableProps) { const [sortId, setSortId] = React.useState(defaultSortId); const [sortDir, setSortDir] = React.useState<'asc' | 'desc'>(defaultSortDir); const sortedData = React.useMemo(() => { if (!sortId) return data; const col = columns.find((c) => c.id === sortId); if (!col || !col.sortValue) return data; const getValue = col.sortValue; const sorted = [...data].sort((a, b) => { const va = getValue(a); const vb = getValue(b); if (va === vb) return 0; if (typeof va === 'number' && typeof vb === 'number') { return sortDir === 'asc' ? va - vb : vb - va; } return sortDir === 'asc' ? String(va).localeCompare(String(vb), 'vi') : String(vb).localeCompare(String(va), 'vi'); }); return sorted; }, [data, columns, sortId, sortDir]); function toggleSort(colId: string) { if (sortId !== colId) { setSortId(colId); setSortDir('desc'); } else { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } } return (
{columns.map((col) => { const active = col.sortable && sortId === col.id; return ( ); })} {loading ? ( ) : sortedData.length === 0 ? ( ) : ( sortedData.map((row, index) => { const key = getRowId ? getRowId(row, index) : index; return ( onRowClick(row) : undefined} onMouseEnter={onRowHover ? () => onRowHover(row) : undefined} onMouseLeave={onRowHover ? () => onRowHover(null) : undefined} className={cn( 'border-b border-border/60 transition-colors duration-100', dense ? 'h-row' : 'h-10', index % 2 === 1 && 'bg-background-surface/40', onRowClick && [ 'cursor-pointer', 'hover:bg-background-surface', 'active:bg-accent/10', 'focus-within:bg-background-surface', ], )} > {columns.map((col) => ( ))} ); }) )}
toggleSort(col.id) : undefined} > {col.header} {col.sortable ? ( {active ? ( sortDir === 'asc' ? ( ) : ( ) ) : ( )} ) : null}
Đang tải...
{emptyText}
{col.cell(row, index)}
); }