diff --git a/src/app/(app)/deliveries/page.tsx b/src/app/(app)/deliveries/page.tsx index 45f96e3..d1f541f 100644 --- a/src/app/(app)/deliveries/page.tsx +++ b/src/app/(app)/deliveries/page.tsx @@ -22,17 +22,22 @@ * * Grouping hierarchy on every tab: * - * Customer X - * Order ORD-2026-01 - * ☑ M10 Bolt (qty 50 @ R 1.20) - * ☐ M12 Nut (qty 30 @ R 2.10) - * Order ORD-2026-02 - * ☐ M8 Bolt (qty 100 @ R 0.50) - * [Load all] [Load 1 selected] - * - * Customer Y + * Company X + * Customer A + * Order ORD-2026-01 + * ☑ M10 Bolt (qty 50 @ R 1.20) + * ☐ M12 Nut (qty 30 @ R 2.10) + * ▾ DN26071401 (combined paper for this order — plus + * items contributed by orders below) + * • M10 Bolt + * • M12 Nut + * • M8 Washer (from order ORD-2026-02) + * Order ORD-2026-02 + * ☐ M8 Bolt (qty 100 @ R 0.50) + * Customer B + * ... + * Company Y * ... - * [Load all selected across all customers (3)] * * Search box hits every field the office might type into: * Server-side (PB filter): @@ -84,6 +89,7 @@ import Link from "next/link"; // and revert when the project-wide lint config catches up.) import { toast } from "sonner"; import { + Building2, CheckSquare, ChevronDown, ChevronRight, @@ -130,10 +136,13 @@ 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; + // Human-readable DN id (e.g. `DN26091405`) this item was + // assembled into. Used by the per-row expand panel to filter + // items belonging to a specific DN. This is the column + // actually populated in PB; the schema also has a relation + // field `delivery_note` that's vestigial — see the join + // comment in `DeliveryNoteRowComponent` below. + delivery_note_number: string | null; unit: string | null; order: { id: string; @@ -173,6 +182,14 @@ type DeliveryNoteRow = { type DeliveryListResponse = { items: DeliveryItem[]; delivery_notes: DeliveryNoteRow[]; + /** + * Items linked to the page's DNs, regardless of `item_status`. + * Drives the DN expand panel so it can show every item on the + * DN — same scope as the full /delivery-notes/{id} page. Items + * already in `items` are deduped by id, so the main list and + * the expand panel can never disagree about a row's data. + */ + dn_items?: DeliveryItem[]; totalItems: number; totalDeliveryNotes: number; page: number; @@ -213,11 +230,25 @@ const TAB_DEFS: Array<{ ]; // ──────────────────────────────────────────────────────────────────── -// Grouping — by customer, then by order. Ad-hoc rows (no parent -// order) land in a single "(no order)" bucket at the bottom of -// their customer group. +// Grouping — by company, then by customer, then by order. A single +// delivery note can combine items from multiple orders (the +// "combine DNs" flow), so DNs sit under whichever order the API +// reports as their primary order; when the operator expands a DN +// row, the page looks up its items across the entire page's +// visible items list, not just the current order's items. +// +// Ad-hoc rows (no parent order) land in a single "(no order)" +// bucket at the bottom of their customer group; ad-hoc customers +// (order exists but no customer) land in "(no customer)" under +// their company. // ──────────────────────────────────────────────────────────────────── +type CompanyGroup = { + companyId: string; + companyName: string; + customers: CustomerGroup[]; +}; + type CustomerGroup = { customerId: string; customerName: string; @@ -231,11 +262,23 @@ type OrderGroup = { deliveryNotes: DeliveryNoteRow[]; }; -function groupByCustomerOrder( +function groupByCompanyCustomerOrder( items: DeliveryItem[], deliveryNotes: DeliveryNoteRow[] = [], -): CustomerGroup[] { - const customerMap = new Map(); +): CompanyGroup[] { + const companyMap = new Map(); + const ensureCustomer = ( + company: CompanyGroup, + customerId: string, + customerName: string, + ): CustomerGroup => { + let customer = company.customers.find((c) => c.customerId === customerId); + if (!customer) { + customer = { customerId, customerName, orders: [] }; + company.customers.push(customer); + } + return customer; + }; const ensureOrder = ( customer: CustomerGroup, orderId: string, @@ -250,15 +293,23 @@ function groupByCustomerOrder( }; for (const item of items) { + // Fallback chain: company → customer → "(no order)". When an + // item is counter-pickup / ad-hoc with no parent order, we drop + // it under a single "(no order)" bucket at the bottom of its + // customer group; if the customer is also missing, "(no + // customer)" under "(no company)". + const companyId = item.order?.company_id ?? "_no_order_"; + const companyName = item.order?.company_name ?? "(no order)"; const customerId = item.order?.customer_id ?? "_no_order_"; const customerName = item.order?.customer_name ?? item.order?.company_name ?? "(no order)"; - let customer = customerMap.get(customerId); - if (!customer) { - customer = { customerId, customerName, orders: [] }; - customerMap.set(customerId, customer); + let company = companyMap.get(companyId); + if (!company) { + company = { companyId, companyName, customers: [] }; + companyMap.set(companyId, company); } + const customer = ensureCustomer(company, customerId, customerName); const orderId = item.order?.id ?? "_no_order_"; const orderNumber = item.order?.order_number ?? "Counter pickup"; @@ -267,15 +318,18 @@ function groupByCustomerOrder( } for (const dn of deliveryNotes) { + const companyId = dn.order?.company_id ?? "_no_order_"; + const companyName = dn.order?.company_name ?? "(no order)"; const customerId = dn.order?.customer_id ?? "_no_order_"; const customerName = dn.order?.customer_name ?? dn.order?.company_name ?? "(no order)"; - let customer = customerMap.get(customerId); - if (!customer) { - customer = { customerId, customerName, orders: [] }; - customerMap.set(customerId, customer); + let company = companyMap.get(companyId); + if (!company) { + company = { companyId, companyName, customers: [] }; + companyMap.set(companyId, company); } + const customer = ensureCustomer(company, customerId, customerName); const orderId = dn.order?.id ?? "_no_order_"; const orderNumber = dn.order?.order_number ?? "Counter pickup"; @@ -283,15 +337,24 @@ function groupByCustomerOrder( order.deliveryNotes.push(dn); } - // Stable order: customers alphabetical, orders inside customer - // by order_number descending so the newest order sits on top. - return [...customerMap.values()] - .sort((a, b) => a.customerName.localeCompare(b.customerName)) + // Stable order: companies alphabetical, customers within a + // company alphabetical, orders within a customer by order_number + // descending so the newest order sits on top. The catch-all + // "(no order)" buckets (no parent order / no customer / no + // company) sort last alphabetically — "(", "(no company)", etc. + // all come after any real name. + return [...companyMap.values()] + .sort((a, b) => a.companyName.localeCompare(b.companyName)) .map((c) => ({ ...c, - orders: [...c.orders].sort((a, b) => - b.orderNumber.localeCompare(a.orderNumber), - ), + customers: [...c.customers] + .sort((a, b) => a.customerName.localeCompare(b.customerName)) + .map((cu) => ({ + ...cu, + orders: [...cu.orders].sort((a, b) => + b.orderNumber.localeCompare(a.orderNumber), + ), + })), })); } @@ -343,6 +406,11 @@ export default function DeliveriesPage() { const [debouncedSearch, setDebouncedSearch] = useState(""); const [items, setItems] = useState([]); const [deliveryNotes, setDeliveryNotes] = useState([]); + // Items linked to the page's DNs regardless of item_status — + // feeds the DN expand panel so it shows every item on the DN + // (matching the full /delivery-notes/{id} page). See + // `dn_items` in the deliveries-service response shape. + const [dnItems, setDnItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [totalItems, setTotalItems] = useState(0); @@ -429,6 +497,13 @@ export default function DeliveriesPage() { const data = (await res.json()) as DeliveryListResponse; setItems(data.items); setDeliveryNotes(data.delivery_notes ?? []); + // `dn_items` is optional in the response shape — older + // deployments may not include it yet. Fall back to `items` + // so the expand panel still has something to look up; that + // path only finds items in the current tab's status, which + // is the old behaviour. Once the new service ships, the + // expand panel finds every item regardless of status. + setDnItems(data.dn_items ?? data.items); setTotalItems(data.totalItems); setTotalDeliveryNotes(data.totalDeliveryNotes ?? 0); } catch (err) { @@ -536,7 +611,7 @@ export default function DeliveriesPage() { }, [visibleItems]); const grouped = useMemo( - () => groupByCustomerOrder(visibleItems, visibleDeliveryNotes), + () => groupByCompanyCustomerOrder(visibleItems, visibleDeliveryNotes), [visibleItems, visibleDeliveryNotes], ); @@ -940,10 +1015,10 @@ export default function DeliveriesPage() { ) : (
- {grouped.map((customer) => ( - ( + ))} @@ -1182,6 +1258,74 @@ export default function DeliveriesPage() { * One customer block: name header + one or more order groups. * Pure presentational — receives everything via props. */ +/** + * One company block: company name header + one or more customer + * groups. Pure presentational — receives everything via props. + */ +function CompanyGroupView({ + company, + activeTab, + selectedIds, + onToggleOne, + onToggleAllInOrder, + onLoad, + onCheckIn, + onSignedCopy, + onMarkLoaded, + busy, + dnItems, +}: { + company: CompanyGroup; + activeTab: DeliveryTab; + selectedIds: Set; + onToggleOne: (id: string) => void; + onToggleAllInOrder: (order: OrderGroup) => void; + onLoad: (itemIds: string[]) => void; + onCheckIn: (itemIds: string[]) => void; + onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void; + onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void; + busy: boolean; + /** + * Every item linked to one of this company's DNs, regardless + * of `item_status`. Used by the per-DN expand panel so it + * shows every item on the DN — same scope as the full + * /delivery-notes/{id} page. Forwarded all the way down to + * DeliveryNoteRowComponent. + */ + dnItems: DeliveryItem[]; +}) { + return ( +
+
+ + {company.companyName} +
+
+ {company.customers.map((customer) => ( + + ))} +
+
+ ); +} + +/** + * One customer block: customer name header + one or more order + * groups. Pure presentational — receives everything via props. + */ function CustomerGroupView({ customer, activeTab, @@ -1193,6 +1337,7 @@ function CustomerGroupView({ onSignedCopy, onMarkLoaded, busy, + dnItems, }: { customer: CustomerGroup; activeTab: DeliveryTab; @@ -1204,6 +1349,8 @@ function CustomerGroupView({ onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void; onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void; busy: boolean; + /** Forwarded to OrderGroupView → DeliveryNoteRowComponent. */ + dnItems: DeliveryItem[]; }) { return (
@@ -1225,6 +1372,7 @@ function CustomerGroupView({ onSignedCopy={onSignedCopy} onMarkLoaded={onMarkLoaded} busy={busy} + dnItems={dnItems} /> ))}
@@ -1248,6 +1396,7 @@ function OrderGroupView({ onSignedCopy, onMarkLoaded, busy, + dnItems, }: { order: OrderGroup; activeTab: DeliveryTab; @@ -1259,6 +1408,13 @@ function OrderGroupView({ onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void; onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void; busy: boolean; + /** + * Every item linked to one of the page's DNs, regardless of + * `item_status`. Forwarded to DeliveryNoteRowComponent so a + * DN's expand panel can find items that belong to the DN but + * live under a different order in the page's grouping. + */ + dnItems: DeliveryItem[]; }) { // Set of DN ids whose items panel is expanded. Independent state // per DN; resets on refresh. Spec § Feature 1 — Interaction details. @@ -1384,7 +1540,7 @@ function OrderGroupView({ busy={busy} expanded={expandedDnIds.has(dn.id)} onToggle={() => toggleDnExpanded(dn.id)} - items={order.items} + items={dnItems} /> ))} @@ -1553,12 +1709,29 @@ 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. + // Items on this DN — drawn from the page-wide visible items + // list passed in via `items`. A DN can combine items from + // multiple orders (the "combine DNs" flow), so the items can + // live under any order in the page's grouping. Looking them up + // here against the full list (rather than restricting to a + // single order's items) is what lets the expand panel show + // every item on the DN. + // + // Join key: `delivery_note_number` (the human-readable DN id, + // e.g. `DN26091405`). This is the column actually populated + // on order_items; the schema also has a relation field + // `delivery_note` but nothing writes to it. Earlier revisions + // of this code compared `i.delivery_note === dn.id` — that's + // why every DN on /deliveries used to show "no items loaded + // on this delivery note" even when the same DN had items + // visible on the full /delivery-notes/{id} page (which joins + // via `delivery_note_number`). The full DN page joins by the + // text column; this filter does the same. + // + // Stale-by-design: a refresh picks up changes. const dnItems = useMemo( - () => items.filter((i) => i.delivery_note === dn.id), - [items, dn.id], + () => items.filter((i) => i.delivery_note_number === dn.delivery_note_number), + [items, dn.delivery_note_number], ); // Header-line suffix logic per spec: suffix omitted when all diff --git a/src/lib/server/deliveries-service.ts b/src/lib/server/deliveries-service.ts index bd4812e..4189bf2 100644 --- a/src/lib/server/deliveries-service.ts +++ b/src/lib/server/deliveries-service.ts @@ -92,6 +92,15 @@ export type DeliveryListResult = { cost_price: number | null; item_status: string; delivery_reference: string | null; + /** + * Human-readable DN id (e.g. `DN26091405`) this item + * belongs to. The /deliveries page's DN expand panel + * joins on this field against each delivery note's + * `delivery_note_number`. The PB schema's relation + * field `delivery_note` exists but nothing writes to + * it; the text column is the actual join key. + */ + delivery_note_number: string | null; /** * QuickBooks invoice number, extracted from the nested * `order_item_details.invoice.number` JSON. Null for rows @@ -135,6 +144,35 @@ export type DeliveryListResult = { company_name: string | null; } | null; }>; + /** + * Items linked to the page's DNs regardless of `item_status`. + * Feeds the DN expand panel so it shows every item on the DN + * (same scope as the full /delivery-notes/{id} page). The + * main items list on the page uses `items` only — `dn_items` + * is never rendered as a top-level row. + */ + dn_items: Array<{ + id: string; + description: string; + quantity: number | null; + selling_price: number | null; + cost_price: number | null; + item_status: string; + delivery_reference: string | null; + delivery_note_number: string | null; + invoice_number: string | null; + loaded_at: string | null; + checked_in_at: string | null; + order: { + id: string; + order_number: string; + customer_id: string; + customer_name: string | null; + company_id: string; + company_name: string | null; + } | null; + checked_in_by: { id: string; name?: string; email?: string } | null; + }>; totalItems: number; totalDeliveryNotes: number; page: number; @@ -302,7 +340,13 @@ export async function listDeliveryItems(params: { expand: "order.customer,order.company,checked_in_by", fields: "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," + + // `delivery_note_number` is the human-readable DN id (e.g. + // `DN26091405`). The PB schema also has a relation field + // `delivery_note` but nothing currently writes to it — the + // join key everywhere else (the full DN detail page, the + // delivery-notes service) is the text column. We only + // request the column that's actually populated. + "delivery_note_number,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, { @@ -315,6 +359,51 @@ export async function listDeliveryItems(params: { }), ]); + // Query #3 — every order_item that has a `delivery_note_number` + // set to one of the DNs in this response, regardless of the + // item's own `item_status`. The DN expand panel needs these so + // it can show every item on the DN — same scope as the full + // /delivery-notes/{id} page (which joins by `delivery_note_number` + // without filtering on item_status). Without this, a DN whose + // items are still in `delivery_note` status (paper sent, invoice + // not yet created) shows "no items loaded" on the expand panel + // even though the same DN has items on the full detail page. + // The main items list (query #1) is what drives the page-level + // bulk actions; this third query only feeds the per-DN expand + // panel — so its status-unfiltered scope doesn't leak into the + // bulk-action rows. Runs after the first two so we have the DN + // numbers in hand; the wire cost is one extra PB read on top + // of the first two parallel queries. + // + // PB rejects filter strings longer than ~8 KB. ~40 chars per + // `delivery_note_number = "DN..." || ` — 200 candidates keeps + // us under the limit. Same pattern as + // order-delivery-note-link-service.ts. + const dnNumbers = (dnsResult.items as unknown as Array<{ delivery_note_number?: string }>) + .map((d) => d.delivery_note_number) + .filter((n): n is string => typeof n === "string" && n.length > 0); + let dnItemsResult: { items: Array>; totalItems: number } = { + items: [], + totalItems: 0, + }; + if (dnNumbers.length > 0) { + const batch = dnNumbers.slice(0, 200); + const dnItemsFilter = batch + .map((n) => `delivery_note_number = "${n.replace(/"/g, '\\"')}"`) + .join(" || "); + dnItemsResult = (await pb + .collection(getOrderItemsCollectionName()) + .getList(1, perPage, { + filter: dnItemsFilter, + sort: "-updated", + expand: "order.customer,order.company", + fields: + "id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," + + "delivery_note_number,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," + + "expand.order,expand.order.customer,expand.order.company", + })) as unknown as { items: Array>; totalItems: number }; + } + type Row = { id: string; description: string; @@ -323,7 +412,15 @@ export async function listDeliveryItems(params: { cost_price: number | null; item_status: string; delivery_reference: string | null; - delivery_note: string | null; + /** + * Human-readable DN id (e.g. `DN26091405`). The /deliveries + * page's DN expand panel uses this to filter the items that + * belong to a given DN — the same join key the full DN detail + * page uses (see `pb-helpers.getDeliveryNoteItems`). The + * relation field `delivery_note` in the schema is never + * populated; this is the column we actually care about. + */ + delivery_note_number: string | null; unit: string | null; loaded_at: string | null; checked_in_at: string | null; @@ -365,7 +462,7 @@ export async function listDeliveryItems(params: { return typeof n === "string" && n.length > 0 ? n : null; } - const items = (itemsResult.items as unknown as Row[]).map((r) => ({ + const mapRow = (r: Row) => ({ id: r.id, description: r.description ?? "", quantity: r.quantity ?? null, @@ -373,7 +470,7 @@ 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, + delivery_note_number: r.delivery_note_number ?? null, unit: r.unit ?? null, invoice_number: readInvoiceNumber(r.order_item_details), loaded_at: r.loaded_at ?? null, @@ -402,7 +499,23 @@ export async function listDeliveryItems(params: { email: r.expand.checked_in_by.email, } : null, - })); + }); + + const items = (itemsResult.items as unknown as Row[]).map(mapRow); + + // Map the third query (DN-linked items, any status) through the + // same mapper. Deduped by item id so the per-DN expand panel + // doesn't render the same row twice when an item already + // appears in `items` (status-filtered) AND `dn_items` + // (DN-linked, any status). A `Map` keyed by item id preserves + // the first occurrence — the status-filtered one, which is the + // version the page renders in the main list. The expand panel + // looks up by item id via `dnItemsById` and never sees a dup. + const dnItemsRaw = (dnItemsResult.items as unknown as Row[]).map(mapRow); + const dnItemsById = new Map(); + for (const it of items) dnItemsById.set(it.id, it); + for (const it of dnItemsRaw) if (!dnItemsById.has(it.id)) dnItemsById.set(it.id, it); + const dn_items = [...dnItemsById.values()]; // Map DN rows. The expand shape mirrors the items query so the // page can group both row kinds by customer → order with the @@ -451,6 +564,14 @@ export async function listDeliveryItems(params: { return { items, delivery_notes, + /** + * Items linked to the page's DNs regardless of `item_status`. + * Feeds the DN expand panel so it shows every item on the + * DN (same scope as the full /delivery-notes/{id} page). + * The main items list on the page uses `items` only — this + * field is never rendered as a top-level row. + */ + dn_items, totalItems: itemsResult.totalItems, totalDeliveryNotes: dnsResult.totalItems, page: itemsResult.page,