Add inline quantity editing for delivery note items: - New PATCH API endpoint and service function to update item quantities - Optimistic UI updates on the detail page with error and loading states - Input validation and status gating for draft/sent delivery notes Add bulk delivery note combine functionality: - New POST API endpoint and service to merge multiple draft/sent notes - Row selection support in DataTable across pagination - Confirmation modal with selected notes preview and optional order linking - Automatic audit trail updates for merged and cancelled records Extend OrderItem TypeScript interface with optional order expand field Fix minor type casting issues in createAdHocDeliveryNote utility
546 lines
18 KiB
TypeScript
546 lines
18 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;
|
|
/**
|
|
* 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,
|
|
}: DataTableProps<T>) {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
const onSearchRef = useRef(onSearch);
|
|
|
|
useEffect(() => {
|
|
onSearchRef.current = onSearch;
|
|
}, [onSearch]);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery]);
|
|
|
|
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="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 border-warn/40 bg-warn/5 focus-visible:border-warn focus-visible:ring-warn/30"
|
|
/>
|
|
</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 ? (
|
|
<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 */}
|
|
<div className="hidden md:block rounded-md border">
|
|
<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. */}
|
|
<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}
|
|
className={cn(
|
|
mobileCardClassName?.(item),
|
|
selected && "ring-1 ring-info/40 rounded-md",
|
|
)}
|
|
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()}
|
|
className="bg-muted/30 hover:bg-muted/30"
|
|
>
|
|
<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;
|
|
}
|