"use client"; import { useState, useEffect, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Checkbox } from "@/components/ui/checkbox"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Search, ChevronLeft, ChevronRight, Loader2, Inbox, ChevronRight as ChevronRightIcon, } from "lucide-react"; interface Column { key: string; label: string; render?: (item: T) => React.ReactNode; sortable?: boolean; className?: string; } /** * Optional expandable-row config. When set, the table adds a small * chevron column at the start of each row; clicking it toggles a * spanned row beneath with the rendered content. Clicking the chevron * does NOT trigger `onRowClick` — the row stays put so the user can * read details without navigating away. */ interface ExpandableConfig { isExpanded: (item: T) => boolean; onToggle: (item: T) => void; renderExpanded: (item: T) => React.ReactNode; /** Optional label for the chevron's `aria-label`. */ ariaLabel?: (item: T) => string; } interface SelectionConfig { /** Whether this item is currently selected. */ isSelected: (item: T) => boolean; /** Toggle selection for this item. */ onToggle: (item: T) => void; /** * Whether selection is disabled for this item (checkbox renders greyed * + the row's click handlers ignore it). Use for items that can't be * acted on in bulk (e.g. terminal-state rows the bulk action would * reject). */ isDisabled?: (item: T) => boolean; } /** * Optional row-selection config. When provided, the table prepends a * checkbox column on desktop + adds a checkbox to each mobile card. The * page owns the selection state (typically a `Set`) and passes the * `isSelected` / `onToggle` callbacks in; DataTable is purely * presentational here. The page can then drive a page-wide bulk action * (e.g. "Combine N selected") from the `actions` prop. * * Click semantics: clicking the checkbox toggles selection without * triggering `onRowClick` (otherwise clicking the checkbox would also * navigate to the detail page). This matters when the row is also * clickable. */ interface DataTableProps { columns: Column[]; data: T[]; loading?: boolean; error?: string; searchPlaceholder?: string; onSearch?: (query: string) => void; page?: number; totalPages?: number; onPageChange?: (page: number) => void; onRowClick?: (item: T) => void; actions?: React.ReactNode; mobileCardRender?: (item: T) => React.ReactNode; /** * Optional slot rendered next to the search input. Used by pages * like `/quotes` to expose an "Include older" toggle that flips * the search-route's recent-window cutoff (Path A scoping). The * DataTable keeps this blind to the host page's behaviour — it * just lays it out. */ searchExtras?: React.ReactNode; /** * Debounce window between the last keystroke and the `onSearch` * callback firing. Default 300 ms — long enough to coalesce a * fast typist into one fetch, short enough to feel responsive. * The `/quotes` page passes 0 because it debounces at the page * level (its `searchInput → search` debounce has extra * side-effects like `setPage(1)` that DataTable can't replicate); * without this knob the quotes page double-debounces to 550 ms. * Mirrors the DataTable-internal 300 ms at the time of writing — * pages that don't pre-debounce can still rely on this default. */ searchDebounceMs?: number; /** * Custom empty-state node rendered when `data.length === 0` and * the table isn't loading. Default is the generic "No records * found / Create your first record" panel. Pages with search pass * a search-specific empty state ("No matches for 'X' — try a * shorter query") so the operator gets a clear next-action hint * instead of a "create your first" prompt that doesn't apply. */ emptyState?: React.ReactNode; /** * Per-row extra class names for the desktop table row. Use for row- * level highlights (e.g. background tint for claimed quotes). */ rowClassName?: (item: T) => string | undefined; /** * Per-row inline style for the desktop table row. Use to set custom * CSS variables or border colours without forcing every consumer to * know the table's internal structure. */ rowStyle?: (item: T) => React.CSSProperties | undefined; /** * Per-row extra class names for the mobile card wrapper. */ mobileCardClassName?: (item: T) => string | undefined; /** * Per-row inline style for the mobile card wrapper. */ mobileCardStyle?: (item: T) => React.CSSProperties | undefined; /** * Expandable-row config. When provided, a chevron column is added on * desktop and a "Show items" toggle is added to the mobile card. */ expandable?: ExpandableConfig; /** * Row-selection config. When provided, a leading checkbox column is * added on desktop + checkboxes are added to each mobile card. See * `SelectionConfig` above for the callback contract. */ selection?: SelectionConfig; } export function DataTable({ columns, data, loading = false, error, searchPlaceholder = "Search...", onSearch, page = 1, totalPages = 1, onPageChange, onRowClick, actions, mobileCardRender, rowClassName, rowStyle, mobileCardClassName, mobileCardStyle, expandable, selection, searchExtras, searchDebounceMs = 300, emptyState, }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const onSearchRef = useRef(onSearch); useEffect(() => { onSearchRef.current = onSearch; }, [onSearch]); useEffect(() => { // 0 ms → call onSearch synchronously (the caller pre-debounces). // Positive values → coalesce a fast typist into one fetch. The // default 300 ms mirrors the pre-existing behaviour so other // pages that don't pre-debounce still feel responsive without // firing a fetch per keystroke. if (searchDebounceMs <= 0) { /* eslint-disable react-hooks/set-state-in-effect */ setDebouncedQuery(searchQuery); /* eslint-enable react-hooks/set-state-in-effect */ return; } const timer = setTimeout( () => setDebouncedQuery(searchQuery), searchDebounceMs, ); return () => clearTimeout(timer); }, [searchQuery, searchDebounceMs]); useEffect(() => { onSearchRef.current?.(debouncedQuery); }, [debouncedQuery]); if (error) { return (

Error loading data

{error}

); } const desktopColCount = columns.length + (expandable ? 1 : 0) + (selection ? 1 : 0); return (
{onSearch && (
setSearchQuery(e.target.value)} className="pl-9" />
{searchExtras}
)} {actions &&
{actions}
}
{loading ? (
) : data.length === 0 ? ( emptyState ?? (

No records found

Create your first record to get started

) ) : ( <> {/* Desktop table */} {/* * `overflow-x-auto` on the wrapper so the table can scroll * horizontally when the viewport is narrower than the table's * natural width (e.g. 768–960 px iPad-landscape, where the * chevron + 7 columns exceed the viewport). Without this the * Actions column is clipped and unreachable on those widths. * The wrapper itself is `hidden md:block` so phones still see * the stacked card layout below. */}
{expandable && } {selection && ( Select )} {columns.map((col) => ( {col.label} ))} {data.map((item, i) => { const expanded = expandable?.isExpanded(item) ?? false; const rowKey = ((item as unknown as Record).id as string) || String(i); return ( ); })}
{/* Mobile cards — {data.map((item, i) => { const expanded = expandable?.isExpanded(item) ?? false; const rowKey = ((item as unknown as Record).id as string) || String(i); const selDisabled = selection?.isDisabled?.(item) ?? false; const selected = selection?.isSelected(item) ?? false; return (
onRowClick(item) : undefined} onKeyDown={ onRowClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item); } } : undefined } className={cn( "rounded-2xl bg-card text-card-foreground shadow-sm shadow-foreground/5 ring-1 ring-foreground/5 overflow-hidden", onRowClick && "cursor-pointer active:scale-[0.99] transition-transform", mobileCardClassName?.(item), selected && "ring-2 ring-info/40", )} style={mobileCardStyle?.(item)} > {selection && (
selection.onToggle(item)} aria-label={ selected ? "Deselect row" : "Select row" } /> {selected ? "Selected" : "Tap to select"}
)} {mobileCardRender ? ( mobileCardRender(item) ) : ( )} {expandable && (
{expanded && (
{expandable.renderExpanded(item)}
)}
)}
); })}
{/* Pagination */} {totalPages > 1 && (

Page {page} of {totalPages}

)} )} ); } /** * Internal: a single data row plus its optional expanded panel below. * Pulled out of the map so we don't have to write the colSpan/expand * logic inline twice. */ function ExpandableRow({ item, columns, onRowClick, rowClassName, rowStyle, expandable, expanded, colSpan, selection, }: { item: T; columns: Column[]; onRowClick?: (item: T) => void; rowClassName?: (item: T) => string | undefined; rowStyle?: (item: T) => React.CSSProperties | undefined; expandable?: ExpandableConfig; expanded: boolean; colSpan: number; selection?: SelectionConfig; }) { const selected = selection?.isSelected(item) ?? false; const selDisabled = selection?.isDisabled?.(item) ?? false; return ( <> onRowClick?.(item)} data-expanded={expanded || undefined} data-selected={selected || undefined} > {expandable && ( )} {selection && ( e.stopPropagation()} > selection.onToggle(item)} aria-label={selected ? "Deselect row" : "Select row"} /> )} {columns.map((col) => ( {col.render ? col.render(item) : String(((item as unknown as Record)[col.key]) ?? "")} ))} {expandable && expanded && ( e.stopPropagation()} // `border-t` + slightly darker bg gives the panel a hard top // edge so the eye can anchor which expanded panel belongs to // which row when multiple rows in a row are expanded. className="bg-muted/40 hover:bg-muted/40 border-t border-border/60" > {expandable.renderExpanded(item)} )} ); } /** * Default fallback mobile card — used when a list page didn't pass a * custom `mobileCardRender`. Renders the first column as the title * (the row's "name"-equivalent) and the second column as a single * supporting line; further columns are stacked underneath in muted * text. The whole card is tappable when `onRowClick` was provided. * * Touch targets: 56px+ on the title row when tappable, so phones get * a comfortable thumb-zone hit area. Description wraps naturally * (no fixed-width truncation). * * Pages with bespoke mobile layouts (e.g. `/sprint`) still pass * `mobileCardRender` and skip this; this card exists so the other 9+ * list pages are usable on mobile with zero per-page wiring. */ function DefaultMobileCard({ item, columns, onRowClick, }: { item: T; columns: Column[]; onRowClick?: (item: T) => void; }) { const titleCol = columns[0]; const subtitleCol = columns[1]; const tail = columns.slice(2); const titleNode = titleCol ? titleCol.render ? titleCol.render(item) : String( (item as unknown as Record)[titleCol.key] ?? "", ) : null; const subtitleNode = subtitleCol ? subtitleCol.render ? subtitleCol.render(item) : String( (item as unknown as Record)[subtitleCol.key] ?? "", ) : null; const CardInner = (
{titleNode}
{subtitleNode && (
{subtitleNode}
)} {tail.length > 0 && (
{tail.map((c) => { const v = c.render ? c.render(item) : String( (item as unknown as Record)[c.key] ?? "", ); return ( {c.label}:{" "} {v} ); })}
)}
); if (onRowClick) { return ( ); } return CardInner; }