feat(deliveries): click-to-expand DN items panel

Adds a chevron button at the left edge of every delivery-note row on
/deliveries. Clicking reveals an inline items panel (description +
qty) so the operator can match against the printed DN paper in their
hand before loading.

Items come from a client-side filter of the order group's items array
(items.filter(i => i.delivery_note === dn.id)). No new server endpoint.
DNs whose items aren't in the order group's slice show a 'See full DN →'
link to /delivery-notes/[id].

Expand state is held at the OrderGroupView level as a Set<string> of
expanded DN ids — independent per DN, resets on refresh. (State lifted
to OrderGroupView rather than DeliveriesPage because order.items is
already in scope there; avoids prop-drilling through CustomerGroupView.)

Service change: deliveries-service.listDeliveryItems now fetches the
delivery_note (relation id) + unit fields on each item, so the page
can filter and render units without an extra round-trip.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-01 12:15:35 +02:00
parent 0984b7513e
commit 2014814c84
2 changed files with 223 additions and 81 deletions

View file

@ -72,6 +72,7 @@
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
/* eslint-disable react-hooks/set-state-in-effect -- the
* "fetch on mount / refetch on tab change" + "clear selection on
* tab change" effects below match the pattern used across this
@ -84,6 +85,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
CheckSquare,
ChevronDown,
ChevronRight,
FileText,
Loader2,
PackageOpen,
@ -127,6 +130,11 @@ type DeliveryItem = {
invoice_number: string | null;
loaded_at: string | null;
checked_in_at: string | null;
// DN this item was assembled into (denormalised from the page's
// /api/v1/deliveries response). Used by the per-row expand panel
// to filter items belonging to a specific DN.
delivery_note: string | null;
unit: string | null;
order: {
id: string;
order_number: string;
@ -1252,6 +1260,23 @@ function OrderGroupView({
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
busy: boolean;
}) {
// Set of DN ids whose items panel is expanded. Independent state
// per DN; resets on refresh. Spec § Feature 1 — Interaction details.
const [expandedDnIds, setExpandedDnIds] = useState<Set<string>>(
() => new Set(),
);
const toggleDnExpanded = useCallback((dnId: string) => {
setExpandedDnIds((prev) => {
const next = new Set(prev);
if (next.has(dnId)) {
next.delete(dnId);
} else {
next.add(dnId);
}
return next;
});
}, []);
const allChecked =
order.items.length > 0 &&
order.items.every((i) => selectedIds.has(i.id));
@ -1357,6 +1382,9 @@ function OrderGroupView({
onSignedCopy={onSignedCopy}
onMarkLoaded={onMarkLoaded}
busy={busy}
expanded={expandedDnIds.has(dn.id)}
onToggle={() => toggleDnExpanded(dn.id)}
items={order.items}
/>
))}
</CardContent>
@ -1478,11 +1506,17 @@ function DeliveryNoteRowComponent({
onSignedCopy,
onMarkLoaded,
busy,
expanded,
onToggle,
items,
}: {
dn: DeliveryNoteRow;
onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
busy: boolean;
expanded: boolean;
onToggle: () => void;
items: DeliveryItem[];
}) {
const statusLabel = (() => {
switch (dn.status) {
@ -1519,95 +1553,199 @@ function DeliveryNoteRowComponent({
// abandoned DN would be misleading.
const canUploadSignedCopy = dn.status !== "cancelled";
// Items on this DN — drawn from this order group's items
// array. Stale-by-design: a refresh picks up changes. See spec
// § Feature 1 — Data source.
const dnItems = useMemo(
() => items.filter((i) => i.delivery_note === dn.id),
[items, dn.id],
);
// Header-line suffix logic per spec: suffix omitted when all
// items have empty/null units (operator default is "EA"); no
// suffix when units are mixed; the shared unit when all items
// share a non-EA unit (e.g. BOX).
const totalSuffix = (() => {
const units = new Set(
dnItems.map((i) => (i.unit ?? "").trim()).filter((u) => u.length > 0),
);
if (units.size === 0) return "";
if (units.size === 1) {
const only = [...units][0];
return only === "EA" ? "" : ` ${only}`;
}
return "";
})();
const totalQty = dnItems.reduce((sum, i) => sum + (i.quantity ?? 0), 0);
const itemsPanelId = `dn-items-${dn.id}`;
const dnOrderId = dn.order?.id ?? null;
const hasDifferentOrderItem = (itemOrderId: string | null) =>
dnOrderId !== null && itemOrderId !== null && itemOrderId !== dnOrderId;
return (
<div
className="flex items-start gap-3 bg-amber-50/40 px-4 py-2.5 dark:bg-amber-950/10"
className="bg-amber-50/40 dark:bg-amber-950/10"
data-testid={`deliveries-dn-row-${dn.id}`}
>
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm" title={dn.delivery_note_number}>
<span className="font-mono">DN {dn.delivery_note_number}</span>
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
{dn.order ? (
<>
<span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
{dn.order.customer_name}
</span>
{dn.order.company_name && (
<>
<span aria-hidden>·</span>
<span>{dn.order.company_name}</span>
</>
)}
{dn.order.order_number && (
<>
<span aria-hidden>·</span>
<span className="font-mono">{dn.order.order_number}</span>
</>
)}
</>
<div className="flex items-start gap-3 px-4 py-2.5">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={itemsPanelId}
aria-label={
expanded
? "Hide items on this delivery note"
: "Show items on this delivery note"
}
className="mt-0.5 shrink-0 rounded p-0.5 text-amber-700 hover:bg-amber-100/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40 dark:text-amber-400 dark:hover:bg-amber-900/30"
>
{expanded ? (
<ChevronDown className="h-4 w-4" aria-hidden />
) : (
<span className="italic">Counter pickup</span>
<ChevronRight className="h-4 w-4" aria-hidden />
)}
{dn.signed_copy && dn.signed_copy.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1"
title={dn.signed_copy.join(", ")}
</button>
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm" title={dn.delivery_note_number}>
<span className="font-mono">DN {dn.delivery_note_number}</span>
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
{dn.order ? (
<>
<span className="truncate max-w-[40ch]" title={dn.order.customer_name ?? ""}>
{dn.order.customer_name}
</span>
{dn.order.company_name && (
<>
<span aria-hidden>·</span>
<span>{dn.order.company_name}</span>
</>
)}
{dn.order.order_number && (
<>
<span aria-hidden>·</span>
<span className="font-mono">{dn.order.order_number}</span>
</>
)}
</>
) : (
<span className="italic">Counter pickup</span>
)}
{dn.signed_copy && dn.signed_copy.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1"
title={dn.signed_copy.join(", ")}
>
<Paperclip className="h-3 w-3" aria-hidden />
signed copy
</span>
</>
)}
{dn.delivery_date && (
<>
<span aria-hidden>·</span>
<span>Delivery {dn.delivery_date}</span>
</>
)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<Badge variant="outline" className="text-[10px]">
{statusLabel}
</Badge>
{canMarkLoaded && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() =>
onMarkLoaded(dn.id, dn.delivery_note_number)
}
disabled={busy}
data-testid={`deliveries-dn-mark-loaded-${dn.id}`}
title="Mark the paper as loaded on the truck"
>
<Truck className="mr-1 h-3 w-3" />
{dn.status === "loaded" ? "Re-mark loaded" : "Mark loaded"}
</Button>
)}
{canUploadSignedCopy && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() =>
onSignedCopy(dn.id, dn.delivery_note_number)
}
disabled={busy}
data-testid={`deliveries-dn-signed-copy-${dn.id}`}
title="Upload the signed copy of this delivery note and mark it complete"
>
<Pencil className="mr-1 h-3 w-3" />
Signed copy
</Button>
)}
</div>
</div>
{expanded && (
<div
id={itemsPanelId}
data-testid={`deliveries-dn-items-${dn.id}`}
className="ml-9 mr-4 mb-2 rounded-md border border-amber-200/60 bg-muted/30 px-3 py-2 text-xs dark:border-amber-900/40"
>
{dnItems.length === 0 ? (
<div className="flex items-center justify-between gap-2 text-muted-foreground">
<span>No items loaded on this delivery note.</span>
<Link
href={`/delivery-notes/${dn.id}`}
className="font-medium text-primary hover:underline"
>
<Paperclip className="h-3 w-3" aria-hidden />
signed copy
</span>
</>
)}
{dn.delivery_date && (
See full DN →
</Link>
</div>
) : (
<>
<span aria-hidden>·</span>
<span>Delivery {dn.delivery_date}</span>
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
<span>
{dnItems.length} item{dnItems.length === 1 ? "" : "s"}
{totalQty > 0 && ` · Total ${totalQty}${totalSuffix}`}
</span>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-amber-200/40 dark:divide-amber-900/40">
{dnItems.map((item) => (
<div
key={item.id}
className="flex items-baseline gap-3 py-1.5"
data-testid={`deliveries-dn-item-${item.id}`}
>
<div className="min-w-0 flex-1">
<p
className="truncate"
title={item.description ?? ""}
>
{item.description}
</p>
{hasDifferentOrderItem(item.order?.id ?? null) && (
<p className="text-[10px] text-muted-foreground">
from order {item.order?.order_number ?? item.order?.id}
</p>
)}
</div>
<span className="shrink-0 tabular-nums text-muted-foreground">
Qty {item.quantity ?? 0}
</span>
</div>
))}
</div>
</>
)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<Badge variant="outline" className="text-[10px]">
{statusLabel}
</Badge>
{canMarkLoaded && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() =>
onMarkLoaded(dn.id, dn.delivery_note_number)
}
disabled={busy}
data-testid={`deliveries-dn-mark-loaded-${dn.id}`}
title="Mark the paper as loaded on the truck"
>
<Truck className="mr-1 h-3 w-3" />
{dn.status === "loaded" ? "Re-mark loaded" : "Mark loaded"}
</Button>
)}
{canUploadSignedCopy && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() =>
onSignedCopy(dn.id, dn.delivery_note_number)
}
disabled={busy}
data-testid={`deliveries-dn-signed-copy-${dn.id}`}
title="Upload the signed copy of this delivery note and mark it complete"
>
<Pencil className="mr-1 h-3 w-3" />
Signed copy
</Button>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -301,8 +301,8 @@ export async function listDeliveryItems(params: {
// all items. Verified live against PB 2026-08-12.
expand: "order.customer,order.company,checked_in_by",
fields:
"id,description,quantity,selling_price,cost_price,item_status,delivery_reference," +
"loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," +
"id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," +
"delivery_note,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," +
"expand.order,expand.order.customer,expand.order.company,expand.checked_in_by",
}),
pb.collection(DELIVERY_NOTES).getList(page, perPage, {
@ -323,6 +323,8 @@ export async function listDeliveryItems(params: {
cost_price: number | null;
item_status: string;
delivery_reference: string | null;
delivery_note: string | null;
unit: string | null;
loaded_at: string | null;
checked_in_at: string | null;
checked_in_by: string | null;
@ -371,6 +373,8 @@ export async function listDeliveryItems(params: {
cost_price: r.cost_price ?? null,
item_status: r.item_status,
delivery_reference: r.delivery_reference ?? null,
delivery_note: r.delivery_note ?? null,
unit: r.unit ?? null,
invoice_number: readInvoiceNumber(r.order_item_details),
loaded_at: r.loaded_at ?? null,
checked_in_at: r.checked_in_at ?? null,