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) => {