629 lines
22 KiB
TypeScript
629 lines
22 KiB
TypeScript
"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<T = unknown> {
|
||
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<T> {
|
||
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<T> {
|
||
/** 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<id>`) 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<T = unknown> {
|
||
columns: Column<T>[];
|
||
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<T>;
|
||
/**
|
||
* 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<T>;
|
||
}
|
||
|
||
export function DataTable<T>({
|
||
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<T>) {
|
||
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 (
|
||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||
<Inbox className="h-12 w-12 mb-4" />
|
||
<p className="text-lg font-medium">Error loading data</p>
|
||
<p className="text-sm">{error}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const desktopColCount =
|
||
columns.length + (expandable ? 1 : 0) + (selection ? 1 : 0);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||
{onSearch && (
|
||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center w-full sm:max-w-2xl">
|
||
<div className="relative w-full sm:max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder={searchPlaceholder}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
{searchExtras}
|
||
</div>
|
||
)}
|
||
{actions && <div className="flex gap-2">{actions}</div>}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : data.length === 0 ? (
|
||
emptyState ?? (
|
||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||
<Inbox className="h-12 w-12 mb-4" />
|
||
<p className="text-lg font-medium">No records found</p>
|
||
<p className="text-sm">Create your first record to get started</p>
|
||
</div>
|
||
)
|
||
) : (
|
||
<>
|
||
{/* 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.
|
||
*/}
|
||
<div className="hidden md:block rounded-md border overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{expandable && <TableHead className="w-8 p-0" />}
|
||
{selection && (
|
||
<TableHead className="w-8 px-2 py-1">
|
||
<span className="sr-only">Select</span>
|
||
</TableHead>
|
||
)}
|
||
{columns.map((col) => (
|
||
<TableHead key={col.key} className={col.className}>
|
||
{col.label}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.map((item, i) => {
|
||
const expanded = expandable?.isExpanded(item) ?? false;
|
||
const rowKey =
|
||
((item as unknown as Record<string, unknown>).id as string) || String(i);
|
||
return (
|
||
<ExpandableRow
|
||
key={rowKey}
|
||
item={item}
|
||
columns={columns}
|
||
onRowClick={onRowClick}
|
||
rowClassName={rowClassName}
|
||
rowStyle={rowStyle}
|
||
expandable={expandable}
|
||
expanded={expanded}
|
||
colSpan={desktopColCount}
|
||
selection={selection}
|
||
/>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* Mobile cards — <md (768px). Desktop table switches to
|
||
`hidden md:block`, mobile cards take over. Always rendered
|
||
so every list page is mobile-usable even without a custom
|
||
`mobileCardRender` — the default below reads the first
|
||
two columns as a title + subtitle.
|
||
The wrapping div handles the row click when `onRowClick`
|
||
is set, so a custom `mobileCardRender` doesn't have to
|
||
wire its own click target. Inner interactive elements
|
||
(selection checkbox, "Show items" toggle) already
|
||
stopPropagation. */}
|
||
<div className="md:hidden space-y-3">
|
||
{data.map((item, i) => {
|
||
const expanded = expandable?.isExpanded(item) ?? false;
|
||
const rowKey =
|
||
((item as unknown as Record<string, unknown>).id as string) || String(i);
|
||
const selDisabled =
|
||
selection?.isDisabled?.(item) ?? false;
|
||
const selected = selection?.isSelected(item) ?? false;
|
||
return (
|
||
<div
|
||
key={rowKey}
|
||
role={onRowClick ? "button" : undefined}
|
||
tabIndex={onRowClick ? 0 : undefined}
|
||
onClick={onRowClick ? () => 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 && (
|
||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||
<Checkbox
|
||
checked={selected}
|
||
disabled={selDisabled}
|
||
onCheckedChange={() => selection.onToggle(item)}
|
||
aria-label={
|
||
selected ? "Deselect row" : "Select row"
|
||
}
|
||
/>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{selected ? "Selected" : "Tap to select"}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{mobileCardRender ? (
|
||
mobileCardRender(item)
|
||
) : (
|
||
<DefaultMobileCard
|
||
item={item}
|
||
columns={columns}
|
||
onRowClick={onRowClick}
|
||
/>
|
||
)}
|
||
{expandable && (
|
||
<div className="px-4 pb-3 pt-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 px-2 text-xs w-full justify-start"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
expandable.onToggle(item);
|
||
}}
|
||
aria-expanded={expanded}
|
||
>
|
||
<ChevronRightIcon
|
||
className={cn(
|
||
"h-3.5 w-3.5 mr-1 transition-transform",
|
||
expanded && "rotate-90"
|
||
)}
|
||
/>
|
||
{expanded ? "Hide items" : "Show items"}
|
||
</Button>
|
||
{expanded && (
|
||
<div className="mt-2 border-t pt-2">
|
||
{expandable.renderExpanded(item)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-muted-foreground">
|
||
Page {page} of {totalPages}
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onPageChange?.(page - 1)}
|
||
disabled={page <= 1}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => onPageChange?.(page + 1)}
|
||
disabled={page >= totalPages}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<T>({
|
||
item,
|
||
columns,
|
||
onRowClick,
|
||
rowClassName,
|
||
rowStyle,
|
||
expandable,
|
||
expanded,
|
||
colSpan,
|
||
selection,
|
||
}: {
|
||
item: T;
|
||
columns: Column<T>[];
|
||
onRowClick?: (item: T) => void;
|
||
rowClassName?: (item: T) => string | undefined;
|
||
rowStyle?: (item: T) => React.CSSProperties | undefined;
|
||
expandable?: ExpandableConfig<T>;
|
||
expanded: boolean;
|
||
colSpan: number;
|
||
selection?: SelectionConfig<T>;
|
||
}) {
|
||
const selected = selection?.isSelected(item) ?? false;
|
||
const selDisabled = selection?.isDisabled?.(item) ?? false;
|
||
return (
|
||
<>
|
||
<TableRow
|
||
className={cn(
|
||
onRowClick && "cursor-pointer",
|
||
rowClassName?.(item),
|
||
selected && "bg-info/5",
|
||
)}
|
||
style={rowStyle?.(item)}
|
||
onClick={() => onRowClick?.(item)}
|
||
data-expanded={expanded || undefined}
|
||
data-selected={selected || undefined}
|
||
>
|
||
{expandable && (
|
||
<TableCell className="w-8 p-0 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
expandable.onToggle(item);
|
||
}}
|
||
aria-expanded={expanded}
|
||
aria-label={
|
||
expandable.ariaLabel
|
||
? expandable.ariaLabel(item)
|
||
: expanded
|
||
? "Hide details"
|
||
: "Show details"
|
||
}
|
||
className="inline-flex items-center justify-center h-8 w-8 rounded hover:bg-accent/60 text-muted-foreground"
|
||
>
|
||
<ChevronRightIcon
|
||
className={cn(
|
||
"h-4 w-4 transition-transform duration-150",
|
||
expanded && "rotate-90"
|
||
)}
|
||
/>
|
||
</button>
|
||
</TableCell>
|
||
)}
|
||
{selection && (
|
||
<TableCell
|
||
className="w-8 px-2 py-1"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={selected}
|
||
disabled={selDisabled}
|
||
onCheckedChange={() => selection.onToggle(item)}
|
||
aria-label={selected ? "Deselect row" : "Select row"}
|
||
/>
|
||
</TableCell>
|
||
)}
|
||
{columns.map((col) => (
|
||
<TableCell key={col.key} className={col.className}>
|
||
{col.render
|
||
? col.render(item)
|
||
: String(((item as unknown as Record<string, unknown>)[col.key]) ?? "")}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
{expandable && expanded && (
|
||
<TableRow
|
||
// The expanded panel is not clickable — we stop propagation so
|
||
// clicking inside the panel (e.g. on a link or button) doesn't
|
||
// bubble up and trigger the row's onClick handler.
|
||
onClick={(e) => 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"
|
||
>
|
||
<TableCell colSpan={colSpan} className="p-0">
|
||
{expandable.renderExpanded(item)}
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<T>({
|
||
item,
|
||
columns,
|
||
onRowClick,
|
||
}: {
|
||
item: T;
|
||
columns: Column<T>[];
|
||
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<string, unknown>)[titleCol.key] ?? "",
|
||
)
|
||
: null;
|
||
const subtitleNode = subtitleCol
|
||
? subtitleCol.render
|
||
? subtitleCol.render(item)
|
||
: String(
|
||
(item as unknown as Record<string, unknown>)[subtitleCol.key] ?? "",
|
||
)
|
||
: null;
|
||
|
||
const CardInner = (
|
||
<div
|
||
className={cn(
|
||
"rounded-md border bg-card text-card-foreground p-3 space-y-1.5",
|
||
onRowClick && "cursor-pointer transition-colors hover:bg-muted/30",
|
||
)}
|
||
>
|
||
<div className="font-medium text-sm leading-tight truncate">
|
||
{titleNode}
|
||
</div>
|
||
{subtitleNode && (
|
||
<div className="text-xs text-muted-foreground leading-snug line-clamp-2">
|
||
{subtitleNode}
|
||
</div>
|
||
)}
|
||
{tail.length > 0 && (
|
||
<div className="flex flex-wrap gap-x-3 gap-y-1 pt-1 border-t border-border/50 text-[11px] text-muted-foreground">
|
||
{tail.map((c) => {
|
||
const v = c.render
|
||
? c.render(item)
|
||
: String(
|
||
(item as unknown as Record<string, unknown>)[c.key] ?? "",
|
||
);
|
||
return (
|
||
<span key={c.key}>
|
||
<span className="text-foreground/60">{c.label}:</span>{" "}
|
||
<span className="text-foreground/90">{v}</span>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
if (onRowClick) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRowClick(item)}
|
||
className="block w-full text-left min-h-[56px]"
|
||
>
|
||
{CardInner}
|
||
</button>
|
||
);
|
||
}
|
||
return CardInner;
|
||
}
|