"use client"; import { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; 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 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; /** * 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; } export function DataTable({ columns, data, loading = false, error, searchPlaceholder = "Search...", onSearch, page = 1, totalPages = 1, onPageChange, onRowClick, actions, mobileCardRender, rowClassName, rowStyle, mobileCardClassName, mobileCardStyle, expandable, }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300); return () => clearTimeout(timer); }, [searchQuery]); useEffect(() => { onSearch?.(debouncedQuery); }, [debouncedQuery, onSearch]); if (error) { return (

Error loading data

{error}

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

No records found

Create your first record to get started

) : ( <> {/* Desktop table */}
{expandable && } {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); return (
{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, }: { 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; }) { return ( <> onRowClick?.(item)} data-expanded={expanded || undefined} > {expandable && ( )} {columns.map((col) => ( {col.render ? col.render(item) : String(((item as unknown as Record)[col.key]) ?? "")} ))} {expandable && expanded && ( e.stopPropagation()} className="bg-muted/30 hover:bg-muted/30" > {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; }