2026-06-22 16:29:59 +00:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-07-10 08:47:11 +00:00
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
2026-06-22 16:29:59 +00:00
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-08-13 09:24:05 +00:00
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
2026-06-22 16:29:59 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 09:24:05 +00:00
|
|
|
|
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.
|
|
|
|
|
|
*/
|
2026-06-22 16:29:59 +00:00
|
|
|
|
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;
|
2026-08-20 08:18:44 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-08-28 18:13:44 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-06-22 16:29:59 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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>;
|
2026-08-13 09:24:05 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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>;
|
2026-06-22 16:29:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-13 09:24:05 +00:00
|
|
|
|
selection,
|
2026-08-20 08:18:44 +00:00
|
|
|
|
searchExtras,
|
2026-08-28 18:13:44 +00:00
|
|
|
|
searchDebounceMs = 300,
|
|
|
|
|
|
emptyState,
|
2026-06-22 16:29:59 +00:00
|
|
|
|
}: DataTableProps<T>) {
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
2026-07-10 08:47:11 +00:00
|
|
|
|
const onSearchRef = useRef(onSearch);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onSearchRef.current = onSearch;
|
|
|
|
|
|
}, [onSearch]);
|
2026-06-22 16:29:59 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-08-28 18:13:44 +00:00
|
|
|
|
// 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,
|
|
|
|
|
|
);
|
2026-06-22 16:29:59 +00:00
|
|
|
|
return () => clearTimeout(timer);
|
2026-08-28 18:13:44 +00:00
|
|
|
|
}, [searchQuery, searchDebounceMs]);
|
2026-06-22 16:29:59 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-10 08:47:11 +00:00
|
|
|
|
onSearchRef.current?.(debouncedQuery);
|
|
|
|
|
|
}, [debouncedQuery]);
|
2026-06-22 16:29:59 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 09:24:05 +00:00
|
|
|
|
const desktopColCount =
|
|
|
|
|
|
columns.length + (expandable ? 1 : 0) + (selection ? 1 : 0);
|
2026-06-22 16:29:59 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
|
|
|
|
|
{onSearch && (
|
2026-08-20 08:18:44 +00:00
|
|
|
|
<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)}
|
2026-08-29 00:30:14 +00:00
|
|
|
|
className="pl-9"
|
2026-08-20 08:18:44 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{searchExtras}
|
2026-06-22 16:29:59 +00:00
|
|
|
|
</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 ? (
|
2026-08-28 18:13:44 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
2026-06-22 16:29:59 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* Desktop table */}
|
2026-08-16 20:24:08 +00:00
|
|
|
|
{/*
|
|
|
|
|
|
* `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">
|
2026-06-22 16:29:59 +00:00
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
{expandable && <TableHead className="w-8 p-0" />}
|
2026-08-13 09:24:05 +00:00
|
|
|
|
{selection && (
|
|
|
|
|
|
<TableHead className="w-8 px-2 py-1">
|
|
|
|
|
|
<span className="sr-only">Select</span>
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
)}
|
2026-06-22 16:29:59 +00:00
|
|
|
|
{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}
|
2026-08-13 09:24:05 +00:00
|
|
|
|
selection={selection}
|
2026-06-22 16:29:59 +00:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-07 15:22:03 +00:00
|
|
|
|
{/* 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. */}
|
2026-07-07 15:22:03 +00:00
|
|
|
|
<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);
|
2026-08-13 09:24:05 +00:00
|
|
|
|
const selDisabled =
|
|
|
|
|
|
selection?.isDisabled?.(item) ?? false;
|
|
|
|
|
|
const selected = selection?.isSelected(item) ?? false;
|
2026-07-07 15:22:03 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-13 09:24:05 +00:00
|
|
|
|
className={cn(
|
2026-08-17 21:34:30 +00:00
|
|
|
|
"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",
|
2026-08-13 09:24:05 +00:00
|
|
|
|
mobileCardClassName?.(item),
|
2026-08-17 21:34:30 +00:00
|
|
|
|
selected && "ring-2 ring-info/40",
|
2026-08-13 09:24:05 +00:00
|
|
|
|
)}
|
2026-07-07 15:22:03 +00:00
|
|
|
|
style={mobileCardStyle?.(item)}
|
|
|
|
|
|
>
|
2026-08-13 09:24:05 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-07 15:22:03 +00:00
|
|
|
|
{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>
|
2026-06-22 16:29:59 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 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,
|
2026-08-13 09:24:05 +00:00
|
|
|
|
selection,
|
2026-06-22 16:29:59 +00:00
|
|
|
|
}: {
|
|
|
|
|
|
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;
|
2026-08-13 09:24:05 +00:00
|
|
|
|
selection?: SelectionConfig<T>;
|
2026-06-22 16:29:59 +00:00
|
|
|
|
}) {
|
2026-08-13 09:24:05 +00:00
|
|
|
|
const selected = selection?.isSelected(item) ?? false;
|
|
|
|
|
|
const selDisabled = selection?.isDisabled?.(item) ?? false;
|
2026-06-22 16:29:59 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
onRowClick && "cursor-pointer",
|
2026-08-13 09:24:05 +00:00
|
|
|
|
rowClassName?.(item),
|
|
|
|
|
|
selected && "bg-info/5",
|
2026-06-22 16:29:59 +00:00
|
|
|
|
)}
|
|
|
|
|
|
style={rowStyle?.(item)}
|
|
|
|
|
|
onClick={() => onRowClick?.(item)}
|
|
|
|
|
|
data-expanded={expanded || undefined}
|
2026-08-13 09:24:05 +00:00
|
|
|
|
data-selected={selected || undefined}
|
2026-06-22 16:29:59 +00:00
|
|
|
|
>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-08-13 09:24:05 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-22 16:29:59 +00:00
|
|
|
|
{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()}
|
2026-08-16 20:24:08 +00:00
|
|
|
|
// `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"
|
2026-06-22 16:29:59 +00:00
|
|
|
|
>
|
|
|
|
|
|
<TableCell colSpan={colSpan} className="p-0">
|
|
|
|
|
|
{expandable.renderExpanded(item)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-07 15:22:03 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|