Files
pn-new-crm/src/components/shared/data-table.tsx
Matt 0ab96d74a8 feat(deps): Tailwind 3 → 4 + swap tailwindcss-animate for tw-animate-css
Ran the official @tailwindcss/upgrade tool:
- tailwind.config.ts → @theme directive in globals.css
- @tailwind base/components/utilities → @import 'tailwindcss'
- postcss.config switched from tailwindcss + autoprefixer to
  @tailwindcss/postcss (autoprefixer baked in)
- focus-visible:outline-none → focus-visible:outline-hidden (the v3
  utility was a footgun — outline still showed in forced-colors mode)

Reverted the migration tool's over-zealous variant="outline" →
variant="outline-solid" rename on CVA prop values; that rename was
meant for the Tailwind `outline:` utility, not our Button/Badge
component variants.

Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css
(v4-native @import). Same utility surface (animate-spin, animate-in,
etc.), one fewer JS plugin in the bundle.

Fixed the upgrade tool's malformed dark variant
(@custom-variant dark (&:is(class *)) — `class` was being parsed as
a tag) to canonical &:where(.dark, .dark *).

Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings),
vitest 1315/1315, next build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:14:38 +02:00

467 lines
18 KiB
TypeScript

'use client';
import { useMemo, useRef, useState } from 'react';
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type Row,
type RowSelectionState,
type VisibilityState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
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<TData> {
columns: ColumnDef<TData, unknown>[];
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 `<TableRow>`. 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<TData>) => 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;
/**
* Opt-in row virtualization. Only renders rows in the viewport (plus a
* small overscan), so a 5000-row client-export list stays at 60 fps.
* The virtualizer uses the surrounding scroll container — set
* `virtualHeightPx` (default 600) so we know the visible area at mount.
*
* Off by default because most CRM tables are server-paginated to 20-50
* rows where virtualization is pure overhead. Pass `virtual` only for
* tables that legitimately hold hundreds-to-thousands of rows in memory.
*
* Constraints:
* - Pagination is incompatible with `virtual` (virtualize ALL rows or
* paginate — pick one). When both are passed, pagination wins and
* virtualization is silently disabled.
* - Mobile card view is unaffected; virtualization only applies to the
* desktop `<Table>` rendering at lg: and up.
*/
virtual?: boolean;
/** Container pixel height when `virtual` is on. Defaults to 600. */
virtualHeightPx?: number;
/** Pixel height of an average row (used for virtualizer sizing). Defaults
* to 48, which matches the Tailwind h-12 cell height shadcn Table uses. */
virtualRowHeightPx?: number;
}
export function DataTable<TData>({
columns,
data,
pagination,
onPaginationChange,
sort,
onSortChange,
rowSelection: externalSelection,
onRowSelectionChange,
bulkActions,
emptyState,
isLoading,
getRowId,
onRowClick,
getRowClassName,
cardRender,
mobileGroupBy,
columnVisibility,
virtual,
virtualHeightPx = 600,
virtualRowHeightPx = 48,
}: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
const rowSelectionState = externalSelection ?? internalSelection;
const setRowSelection = onRowSelectionChange ?? setInternalSelection;
// Memoize the assembled columns array. perf-test-auditor HIGH H2:
// TanStack docs explicitly warn that rebuilding `columns` on every
// render resets the table's internal state (sort, filter, sizing).
// Re-derive only when the source columns or bulkActions presence
// actually change.
const hasBulkActions = Boolean(bulkActions && bulkActions.length > 0);
const allColumns = useMemo<ColumnDef<TData, unknown>[]>(() => {
const cols: ColumnDef<TData, unknown>[] = [];
if (hasBulkActions) {
cols.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
size: 40,
});
}
cols.push(...columns);
return cols;
}, [columns, hasBulkActions]);
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 <ArrowUpDown className="ml-1 h-3.5 w-3.5" />;
return sort.direction === 'asc' ? (
<ArrowUp className="ml-1 h-3.5 w-3.5" />
) : (
<ArrowDown className="ml-1 h-3.5 w-3.5" />
);
}
const rows = table.getRowModel().rows;
// Virtualization gate — pagination wins over virtual (you can't do both).
const virtualEnabled = Boolean(virtual) && !pagination;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: virtualEnabled ? rows.length : 0,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => virtualRowHeightPx,
overscan: 8,
});
const virtualRows = virtualEnabled ? virtualizer.getVirtualItems() : [];
const virtualPaddingTop = virtualRows[0]?.start ?? 0;
const virtualPaddingBottom = virtualEnabled
? virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)
: 0;
return (
<div className="space-y-2">
<div
ref={virtualEnabled ? scrollContainerRef : undefined}
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : undefined}
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
className={cn(
header.column.getCanSort() && onSortChange && 'cursor-pointer select-none',
)}
onClick={() => {
if (
header.column.getCanSort() &&
onSortChange &&
header.column.id !== 'select'
) {
handleSort(header.column.id);
}
}}
>
<div className="flex items-center">
{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)}
</div>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={allColumns.length} className="h-40 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={allColumns.length} className="h-40">
{emptyState ?? (
<div className="text-center text-muted-foreground">No results.</div>
)}
</TableCell>
</TableRow>
) : virtualEnabled ? (
<>
{virtualPaddingTop > 0 ? (
<TableRow style={{ height: virtualPaddingTop }}>
<TableCell colSpan={allColumns.length} className="p-0" />
</TableRow>
) : null}
{virtualRows.map((vRow) => {
const row = rows[vRow.index]!;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(
onRowClick && 'cursor-pointer',
getRowClassName?.(row.original),
)}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})}
{virtualPaddingBottom > 0 ? (
<TableRow style={{ height: virtualPaddingBottom }}>
<TableCell colSpan={allColumns.length} className="p-0" />
</TableRow>
) : null}
</>
) : (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(onRowClick && 'cursor-pointer', getRowClassName?.(row.original))}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Mobile card list */}
{cardRender && (
<ul className="lg:hidden flex flex-col gap-2">
{isLoading ? (
<li className="rounded-md border bg-card p-6 text-center">
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</li>
) : rows.length === 0 ? (
<li className="rounded-md border bg-card p-6 text-center text-sm text-muted-foreground">
{emptyState ?? 'No results.'}
</li>
) : (
(() => {
// Walk rows once, emitting a section header <li> 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(
<li key={`__group_${group ?? '_none'}_${i}`} className="px-1 pt-3">
<div className="flex items-center gap-3 text-base font-bold tracking-tight text-foreground">
<span>{group ?? 'Other'}</span>
<span aria-hidden className="h-px flex-1 bg-border" />
</div>
</li>,
);
lastGroup = group;
}
nodes.push(<li key={row.id}>{cardRender(row)}</li>);
});
return nodes;
})()
)}
</ul>
)}
{/* 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 && (
<div className="flex items-center justify-between px-2 gap-3 flex-wrap">
<div className="text-sm text-muted-foreground">
{selectedIds.length > 0
? `${selectedIds.length} of ${pagination.total} row(s) selected`
: `${pagination.total} row(s) total`}
</div>
<div className="flex items-center gap-3">
{/* 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. */}
<label className="text-sm text-muted-foreground inline-flex items-center gap-1.5">
Show
<select
value={String(pagination.pageSize)}
onChange={(e) => {
const next = e.target.value === 'all' ? 1000 : Number(e.target.value);
onPaginationChange?.(1, next);
}}
className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="all">All</option>
</select>
</label>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
>
Next
</Button>
</div>
)}
</div>
</div>
)}
{/* Bulk actions bar */}
{bulkActions && selectedIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg animate-in slide-in-from-bottom-4">
<span className="text-sm font-medium">{selectedIds.length} selected</span>
{bulkActions.map((action) => (
<Button
key={action.label}
variant={action.variant ?? 'default'}
size="sm"
onClick={() => action.onClick(selectedIds)}
>
{action.icon && <action.icon className="mr-1.5 h-4 w-4" />}
{action.label}
</Button>
))}
</div>
)}
</div>
);
}