diff --git a/src/app/(app)/deliveries/page.tsx b/src/app/(app)/deliveries/page.tsx index ee72cd1..45f96e3 100644 --- a/src/app/(app)/deliveries/page.tsx +++ b/src/app/(app)/deliveries/page.tsx @@ -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>( + () => 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} /> ))} @@ -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 (
- -
-

- DN {dn.delivery_note_number} -

-

- {dn.order ? ( - <> - - {dn.order.customer_name} - - {dn.order.company_name && ( - <> - · - {dn.order.company_name} - - )} - {dn.order.order_number && ( - <> - · - {dn.order.order_number} - - )} - +

+ + )} + {canUploadSignedCopy && ( + + )} +
+
+ {expanded && ( +
+ {dnItems.length === 0 ? ( +
+ No items loaded on this delivery note. + - - signed copy - - - )} - {dn.delivery_date && ( + See full DN → + +
+ ) : ( <> - · - Delivery {dn.delivery_date} +
+ + {dnItems.length} item{dnItems.length === 1 ? "" : "s"} + {totalQty > 0 && ` · Total ${totalQty}${totalSuffix}`} + +
+
+ {dnItems.map((item) => ( +
+
+

+ {item.description} +

+ {hasDifferentOrderItem(item.order?.id ?? null) && ( +

+ from order {item.order?.order_number ?? item.order?.id} +

+ )} +
+ + Qty {item.quantity ?? 0} + +
+ ))} +
)} -

-
-
- - {statusLabel} - - {canMarkLoaded && ( - - )} - {canUploadSignedCopy && ( - - )} -
+
+ )} ); } diff --git a/src/lib/server/deliveries-service.ts b/src/lib/server/deliveries-service.ts index d6ef23d..bd4812e 100644 --- a/src/lib/server/deliveries-service.ts +++ b/src/lib/server/deliveries-service.ts @@ -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,