WFDASH-WEBAPP/src/components/data-table.tsx
Dave 980f40aa79 feat(delivery-notes): see what's on a note, colour-coded progress, POS-aligned stock search
The delivery-notes list was close to unusable: company groups initialised
to an empty Set, so `isOpen` was false for every group and the page landed
on a stack of company headers with nothing under them. The code comment
claimed the opposite of what the code did.

Screen
- Track *collapsed* group names instead of open ones. Open is now the
  default with no initialisation, and it survives a list re-fetch.
- Rows expand to show the real line items (description, qty, supplier,
  SKU, price, subtotal) via the existing /detail read model, cached after
  first open. Ported from /quotes, which already had this affordance.
- Whole-row click removed so expanding can't be mistaken for opening; the
  DN number and "Open full delivery note ->" navigate instead.
- Row tint as a derived progress ladder — no new stored statuses:
    none = draft · yellow = signed copy · blue = quoted · green = po_received
  `invoiced` leaves the Active tab, so Active is now genuinely what is
  outstanding. Hovering the status badge explains the colour.
- DataTable gained an optional `selectionHeaderLabel`; the checkboxes
  feed one action (Combine) and now say so.

Stock search
- The create-delivery-note dialog was `getFullList`-ing every stock item
  with expand=supplier on each open, before a character was typed. Now a
  debounced typeahead against /api/v1/stock-items/search.
- Deliberately NOT the existing quick-draft search route: that one is
  served with an admin client and no session check, so its result isn't
  gated on the listRule. The new route uses assertBrowserSession.

Fixes
- The PDF preview modal was clamped to 384px wide and 900px tall despite
  passing max-w-[1100px]. DialogContent's default `sm:max-w-sm` is a
  *variant*, so twMerge kept both and the variant won on cascade order
  (verified against the compiled stylesheet: 88,536 vs 258,517). Both
  preview variants now repeat the `sm:` prefix so the conflict resolves.
- Expand-panel fetch was missing the Authorization header. Dashboard auth
  lives in localStorage, so route handlers cannot read it from cookies and
  the request 401'd. A 401 now reports an expired session instead of a
  bare status code.

Notes
- 25 new unit tests. The stage ladder and the PB filter construction are
  the two places where a silent wrong answer looks like a UI bug.
- Unauthenticated write routes on delivery-notes (create, combine,
  create-quote, customer-po, mark-invoiced) have no session check. Left
  as-is and reported separately — locking them is an auth-behaviour change.
2026-10-04 20:40:13 +02:00

666 lines
24 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;
/**
* Visible header label for the selection checkbox column.
*
* Defaults to an `sr-only` "Select" — the historical behaviour,
* kept so the ~10 list pages that don't pass this are unchanged.
* Pass a string when the checkboxes drive a *specific* bulk
* action and the operator has no way to guess what from the
* column itself: `/delivery-notes` passes "Combine" because its
* checkboxes feed a Combine-DNs action that otherwise springs into
* view unannounced, and an unlabelled checkbox is unreadable
* ("what does this do?"). Leave it off for a plain
* select-to-filter table where "Select" is self-explanatory.
*/
selectionHeaderLabel?: string;
/**
* 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,
selectionHeaderLabel,
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
// 8rem is enough for a short word like "Combine"
// on one line; the unlabelled variant keeps the
// original 2rem so existing tables don't shift.
className={cn("px-2 py-1", selectionHeaderLabel ? "w-28" : "w-8")}
>
{/*
* A bare checkbox column with no header is the single
* most complained-about thing on these list screens —
* the operator has no way to know what ticking it does.
* `selectionHeaderLabel` makes it say so; without it
* we keep the old screen-reader-only "Select" so the
* other list pages render exactly as before.
*/}
{selectionHeaderLabel ? (
<span
className="block text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
title={`Tick rows to ${selectionHeaderLabel.toLowerCase()}`}
>
{selectionHeaderLabel}
</span>
) : (
<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;
}