'use client'; import { useState } from 'react'; import { flexRender, getCoreRowModel, useReactTable, type ColumnDef, type Row, type RowSelectionState, type VisibilityState, } from '@tanstack/react-table'; import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; export interface DataTablePagination { page: number; pageSize: number; total: number; totalPages: number; } export interface BulkAction { label: string; icon?: React.ElementType; variant?: 'default' | 'destructive'; onClick: (selectedIds: string[]) => void; } interface DataTableProps { columns: ColumnDef[]; data: TData[]; pagination?: DataTablePagination; onPaginationChange?: (page: number, pageSize: number) => void; sort?: { field: string; direction: 'asc' | 'desc' }; onSortChange?: (field: string, direction: 'asc' | 'desc') => void; rowSelection?: RowSelectionState; onRowSelectionChange?: (selection: RowSelectionState) => void; bulkActions?: BulkAction[]; emptyState?: React.ReactNode; isLoading?: boolean; getRowId?: (row: TData) => string; onRowClick?: (row: TData) => void; /** * Optional row class hook — return a string of Tailwind utilities * applied to the ``. Use for visual grouping (e.g. tinting * berths by mooring letter so the kanban-like grid reads at a glance). */ getRowClassName?: (row: TData) => string | undefined; /** * Mobile card renderer. When provided, the table is hidden below `lg:` * and replaced with a vertical list of cards built from this callback. * The same TanStack `table` instance powers both views, so pagination, * sort, and selection stay in sync across the breakpoint. */ cardRender?: (row: Row) => React.ReactNode; /** * Optional grouping key for the mobile card list. When set, consecutive * rows that share the same returned key are visually grouped under a * header showing the key. Rendered only on mobile (next to cardRender); * the desktop table is unaffected. Useful for berths-by-area, * documents-by-folder, etc. — pre-sort the data on the same key so * adjacent rows already share groups. */ mobileGroupBy?: (row: TData) => string | null | undefined; /** * Per-column visibility map. Keys are column IDs, values mean * "currently visible". Columns absent from the map are visible by * default — newly-added columns surface for existing users without * needing a preferences migration. */ columnVisibility?: VisibilityState; } export function DataTable({ columns, data, pagination, onPaginationChange, sort, onSortChange, rowSelection: externalSelection, onRowSelectionChange, bulkActions, emptyState, isLoading, getRowId, onRowClick, getRowClassName, cardRender, mobileGroupBy, columnVisibility, }: DataTableProps) { const [internalSelection, setInternalSelection] = useState({}); const rowSelectionState = externalSelection ?? internalSelection; const setRowSelection = onRowSelectionChange ?? setInternalSelection; const allColumns: ColumnDef[] = []; if (bulkActions && bulkActions.length > 0) { allColumns.push({ id: 'select', header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" className="translate-y-[2px]" /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label="Select row" className="translate-y-[2px]" onClick={(e) => e.stopPropagation()} /> ), enableSorting: false, size: 40, }); } allColumns.push(...columns); const table = useReactTable({ data, columns: allColumns, getCoreRowModel: getCoreRowModel(), manualPagination: true, manualSorting: true, rowCount: pagination?.total ?? data.length, state: { rowSelection: rowSelectionState, pagination: pagination ? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize } : undefined, columnVisibility, }, onRowSelectionChange: (updater) => { const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater; setRowSelection(newSelection); }, getRowId: getRowId as (row: TData, index: number) => string, enableRowSelection: !!bulkActions?.length, }); const selectedIds = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]); function handleSort(columnId: string) { if (!onSortChange) return; if (sort?.field === columnId) { onSortChange(columnId, sort.direction === 'asc' ? 'desc' : 'asc'); } else { onSortChange(columnId, 'asc'); } } function getSortIcon(columnId: string) { if (sort?.field !== columnId) return ; return sort.direction === 'asc' ? ( ) : ( ); } const rows = table.getRowModel().rows; return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( { if ( header.column.getCanSort() && onSortChange && header.column.id !== 'select' ) { handleSort(header.column.id); } }} >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.column.getCanSort() && onSortChange && header.column.id !== 'select' && header.column.id !== 'actions' && getSortIcon(header.column.id)}
))}
))}
{isLoading ? (
Loading...
) : table.getRowModel().rows.length === 0 ? ( {emptyState ?? (
No results.
)}
) : ( table.getRowModel().rows.map((row) => ( onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) )}
{/* Mobile card list */} {cardRender && (
    {isLoading ? (
  • ) : rows.length === 0 ? (
  • {emptyState ?? 'No results.'}
  • ) : ( (() => { // Walk rows once, emitting a section header
  • every time // the groupBy key changes. Keeps the existing flex-col gap-2 // rhythm; the header sits above the first card of each group // with a faint top divider for visual rest between blocks. let lastGroup: string | null | undefined; const nodes: React.ReactNode[] = []; rows.forEach((row, i) => { const group = mobileGroupBy ? mobileGroupBy(row.original) : undefined; if (mobileGroupBy && group !== lastGroup) { nodes.push(
  • {group ?? 'Other'}
  • , ); lastGroup = group; } nodes.push(
  • {cardRender(row)}
  • ); }); return nodes; })() )}
)} {/* Pagination — render whenever pagination is defined so the page-size selector is reachable even on single-page tables. Prev/Next group only renders when there's actually more than one page. */} {pagination && (
{selectedIds.length > 0 ? `${selectedIds.length} of ${pagination.total} row(s) selected` : `${pagination.total} row(s) total`}
{/* Page-size selector — "All" maps to the validator's max(1000) cap. If a port has more than 1000 active rows the user paginates; we don't quietly drop rows. */} {pagination.totalPages > 1 && (
Page {pagination.page} of {pagination.totalPages}
)}
)} {/* Bulk actions bar */} {bulkActions && selectedIds.length > 0 && (
{selectedIds.length} selected {bulkActions.map((action) => ( ))}
)}
); }