WFDASH-WEBAPP/src/components/data-table.tsx

630 lines
22 KiB
TypeScript
Raw Normal View History

"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
2026-09-01 22:41:31 +00:00
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}
2026-09-01 22:41:31 +00:00
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",
2026-09-01 22:41:31 +00:00
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;
}