From 4519288073c7d529bcc5f7ffe085a0bb70a92cc3 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 14 Oct 2026 09:59:25 +0200 Subject: [PATCH] feat(collection-pool): name the assigned driver, link the PO for download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool showed only "Assigned today" — no indication of WHO had the stop — and the PO number was inert text, so an operator chasing a supplier had to open the order to find the paperwork. Driver name order_items.drv_id is a PB record id, so it is useless on screen. The page already loads the user list into `driversById` to pre-fill the assign dialog; that same map now rides onto the row, so the badge reads "Assigned to Ruan" and a second line spells it out with the scheduled date for column scanning. Filtering the pool by driver name now works too, since the name is on the row. The badge keeps its old date-only wording when no name resolves (the lookup is best-effort) and never renders the raw id — "assigned to krpyec9l" is not actionable. Unassigned rows ignore a stale drv_id. PO download The PO number links to `/api/v1/purchase-orders/{id}/pdf?download=1`, matching the order detail page. `version` is deliberately omitted so the link always resolves to the CURRENT document — the one the supplier was sent. Customer rows only. `source: "stock"` rows hang off a `stock_purchase`, a different collection: the purchase-orders PDF route is keyed on `purchase_orders` records and would 404 on a stock id. Those POs live as PB files under `stock_purchases/{id}/` with no download route yet, so their number stays plain text rather than shipping a broken link. Tests: 6 new cases covering driver naming, the unresolved-name fallback, ignoring a stale driver on unassigned rows, driver search, and refusing to match a PB id as if it were a name. --- src/app/(app)/collection-pool/components.tsx | 54 ++++++++++++- src/app/(app)/collection-pool/page.tsx | 13 ++- .../(app)/collection-pool/view-model.test.ts | 81 +++++++++++++++++++ src/app/(app)/collection-pool/view-model.ts | 40 ++++++++- 4 files changed, 181 insertions(+), 7 deletions(-) diff --git a/src/app/(app)/collection-pool/components.tsx b/src/app/(app)/collection-pool/components.tsx index 0de0e7f..ba50fa3 100644 --- a/src/app/(app)/collection-pool/components.tsx +++ b/src/app/(app)/collection-pool/components.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, Truck } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ChevronDown, FileText, Loader2, Truck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -59,6 +59,15 @@ export type CollectionPoolVisibleRow = CollectionPoolRow & { customerName: string; poNumber: string; orderNumber: string; + /** + * PB id of the parent purchase order (`order_items.purchase_order`, or + * the stock purchase for `source: "stock"` rows). Present whenever a + * PO exists, which is what makes the PO number clickable — the + * download route is keyed on the record id, not the number. + */ + purchaseOrderId?: string | null; + /** Resolved driver display name for `drv_id`; see the view model. */ + driverName?: string; }; function markerTokenToClassName(token?: string): string { @@ -98,7 +107,7 @@ export function PoolRow({ onMarkStockCollected: (row: CollectionPoolVisibleRow) => void; }) { const assignmentState = getAssignmentState(row, todayYmd); - const assignmentBadge = getAssignmentBadge(assignmentState); + const assignmentBadge = getAssignmentBadge(assignmentState, row.driverName); const isAssigned = assignmentState !== "unassigned"; const readyLabel = row.ready_for_collection ? "Ready" : "Pending"; const assignedDateLabel = row.assigned_at @@ -189,7 +198,35 @@ export function PoolRow({ )} -
{row.poNumber || "No PO"}
+ {/* PO number → the PO PDF. `download=1` makes the GET route + stream the latest version as an attachment rather than + inline; `version` is intentionally omitted so this always + resolves to the CURRENT document, which is the one the + supplier was actually sent. + + Customer rows ONLY. `source: "stock"` rows hang off a + `stock_purchase`, which is a different collection — the + purchase-orders PDF route is keyed on `purchase_orders` + records and would 404 on a stock_purchase id. Stock POs + live as PB files under `stock_purchases/{id}/{filename}` + and have no download route yet, so their number stays as + plain text rather than shipping a broken link. */} + {!isStock && row.purchaseOrderId ? ( + + + ) : ( +
+ {row.poNumber || "No PO"} +
+ )}
{getPoolAgeLabel(row.updated)}
@@ -202,6 +239,17 @@ export function PoolRow({ )} {assignedDateLabel &&
Scheduled {assignedDateLabel}
} + {/* Named driver on its own line. The badge says "Assigned + to Ruan" — this is the redundant-but-scannable version, + and it carries the date, so an operator scanning the + column can read driver + when without decoding the pill + colour. Suppressed when the lookup failed (no name), so + we never show a bare PB id. */} + {row.driverName && ( +
+ Driver: {row.driverName} +
+ )}
diff --git a/src/app/(app)/collection-pool/page.tsx b/src/app/(app)/collection-pool/page.tsx index 55538e5..7618cdd 100644 --- a/src/app/(app)/collection-pool/page.tsx +++ b/src/app/(app)/collection-pool/page.tsx @@ -451,6 +451,17 @@ export default function CollectionPoolPage() { customerName: row.expand?.order?.expand?.customer?.customer ?? "", poNumber: row.expand?.purchase_order?.po_number ?? "", orderNumber: row.expand?.order?.order_number ?? "", + // The PO record id is what the download route is keyed on + // (`/api/v1/purchase-orders/{id}/pdf`). Both sources funnel + // their parent PO through `expand.purchase_order` — see the + // stock-row mapping in `load()` — so one field covers both. + purchaseOrderId: row.expand?.purchase_order?.id ?? null, + // `driversById` is the same map the assign dialog pre-fills + // from, so the name shown on the row and the name shown in + // the dialog can't drift. Left undefined when the user + // lookup hasn't resolved yet (or failed) — the badge then + // falls back to date-only wording rather than showing an id. + driverName: row.drv_id ? driversById[row.drv_id] : undefined, clusterSortKey: clusterSortKeyForSupplier(supplierId), }; }), @@ -461,7 +472,7 @@ export default function CollectionPoolPage() { sortMode, }, ) as CollectionPoolVisibleRow[]; - }, [rows, todayYmd, filterText, readyFilter, sortMode, clusterSortKeyForSupplier]); + }, [rows, todayYmd, filterText, readyFilter, sortMode, clusterSortKeyForSupplier, driversById]); const groups = useMemo< | (Omit & { rows: CollectionPoolVisibleRow[] })[] diff --git a/src/app/(app)/collection-pool/view-model.test.ts b/src/app/(app)/collection-pool/view-model.test.ts index 47db608..26d72fd 100644 --- a/src/app/(app)/collection-pool/view-model.test.ts +++ b/src/app/(app)/collection-pool/view-model.test.ts @@ -86,6 +86,87 @@ describe("getAssignmentBadge", () => { "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30", }); }); + + it("names the driver when a resolved name is supplied", () => { + // The operator's question on this page is "who has this stop?", + // so the name becomes the headline and the date is the suffix. + expect(getAssignmentBadge("assigned_today", "Ruan").label).toBe( + "Assigned to Ruan", + ); + expect(getAssignmentBadge("assigned_future", "Ruan").label).toBe( + "Ruan · scheduled", + ); + }); + + it("keeps the date-only fallback for an unresolved driver", () => { + // `drv_id` is a PB record id and must never reach the operator — + // "assigned to krpyec9l" is not actionable. Fall back to the + // previous wording instead of printing the id. + expect(getAssignmentBadge("assigned_today", undefined).label).toBe( + "Assigned today", + ); + expect(getAssignmentBadge("assigned_today", "").label).toBe("Assigned today"); + expect(getAssignmentBadge("assigned_today", " ").label).toBe( + "Assigned today", + ); + }); + + it("ignores the driver name when the row is unassigned", () => { + // Defensive: a stale drv_id on a re-pooled row must not claim a + // driver is holding it. + expect(getAssignmentBadge("unassigned", "Ruan").label).toBe("Unassigned"); + }); +}); + +describe("buildVisibleRows — driver search", () => { + it("matches a row by the driver's display name", () => { + const rows = buildVisibleRows( + [ + { + id: "a", + supplierId: "s1", + supplierName: "Afrisam", + description: "Hex bolt M16", + drv_id: "drv1", + driverName: "Ruan", + ready_for_collection: true, + }, + { + id: "b", + supplierId: "s1", + supplierName: "Afrisam", + description: "Nut M16", + drv_id: "drv2", + driverName: "Tiaan", + ready_for_collection: true, + }, + ], + { todayYmd: "2026-10-14", searchText: "ruan", readyFilter: "all" }, + ); + expect(rows.map((r) => r.id)).toEqual(["a"]); + }); + + it("does not match on a PB record id posing as a name", () => { + const rows = buildVisibleRows( + [ + { + id: "a", + supplierId: "s1", + supplierName: "Afrisam", + description: "Hex bolt", + drv_id: "krpyec9lblra0qz", + driverName: undefined, + ready_for_collection: true, + }, + ], + { + todayYmd: "2026-10-14", + searchText: "krpyec9l", + readyFilter: "all", + }, + ); + expect(rows).toHaveLength(0); + }); }); describe("buildVisibleRows", () => { diff --git a/src/app/(app)/collection-pool/view-model.ts b/src/app/(app)/collection-pool/view-model.ts index f3d2956..795183a 100644 --- a/src/app/(app)/collection-pool/view-model.ts +++ b/src/app/(app)/collection-pool/view-model.ts @@ -20,6 +20,18 @@ export type CollectionPoolViewRow = { updated?: string; item_status?: string; drv_id?: string; + /** + * Resolved display name for `drv_id`, e.g. "Ruan". + * + * `drv_id` is a PB record id, so it is useless to an operator. The page + * already loads the user list into a `driversById` map (used to + * pre-fill the assign dialog) — this carries that same name onto the + * row so the badge can say WHO has the stop rather than only that one + * is assigned. Undefined when the row has no driver or the user + * lookup failed; the badge then degrades to the old label rather than + * rendering a raw id. + */ + driverName?: string; assigned_at?: string; ready_for_collection?: boolean | null; /** Stock-only: PB id of the parent `stock_purchase`. */ @@ -71,17 +83,35 @@ export function getPoolAgeLabel(updatedAt: string | undefined, now = new Date()) return `${Math.floor(hours / 24)}d`; } -export function getAssignmentBadge(state: AssignmentState): AssignmentBadge { +/** + * Badge for a row's assignment state. + * + * `driverName` is the resolved display name from `CollectionPoolViewRow`. + * When present it becomes the headline — "Assigned to Ruan" — because the + * operator's question is "who has this?", not "is this assigned?". The + * today/future distinction is kept as a suffix so the scheduling + * signal survives. + * + * Without a resolvable name the badge falls back to the previous + * date-only wording. It never renders the PB record id: an operator + * cannot act on "assigned to krpyec9l". + */ +export function getAssignmentBadge( + state: AssignmentState, + driverName?: string, +): AssignmentBadge { + const who = (driverName || "").trim(); + switch (state) { case "assigned_today": return { - label: "Assigned today", + label: who ? `Assigned to ${who}` : "Assigned today", className: "bg-blue-100 text-blue-800 ring-1 ring-blue-200/60 dark:bg-blue-500/15 dark:text-blue-300 dark:ring-blue-400/30", }; case "assigned_future": return { - label: "Assigned future", + label: who ? `${who} · scheduled` : "Assigned future", className: "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30", }; @@ -120,6 +150,10 @@ export function buildVisibleRows( row.orderNumber ?? "", row.customerName ?? "", row.companyName ?? "", + // Typing a driver's name is the natural way to answer "what + // has Ruan got?" on this page — the badge shows it, so the + // filter must too. + row.driverName ?? "", ].some((value) => value.toLowerCase().includes(query)); }) .sort((a, b) => {