WFDASH-WEBAPP/src/components/data-table.tsx
2026-09-02 00:41:31 +02:00

629 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}