feat(collection-pool): name the assigned driver, link the PO for download

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.
This commit is contained in:
Dave 2026-10-14 09:59:25 +02:00
parent f44258667c
commit 4519288073
4 changed files with 181 additions and 7 deletions

View file

@ -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({
</Link>
</div>
)}
<div className="mt-1 font-mono text-xs text-muted-foreground">{row.poNumber || "No PO"}</div>
{/* 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 ? (
<a
href={`/api/v1/purchase-orders/${row.purchaseOrderId}/pdf?download=1`}
target="_blank"
rel="noopener noreferrer"
title={`Download PO ${row.poNumber} (latest version)`}
className="mt-1 inline-flex items-center gap-1 font-mono text-xs text-primary underline-offset-2 hover:underline"
>
<FileText className="size-3 shrink-0" aria-hidden="true" />
{row.poNumber || `PO ${row.purchaseOrderId.slice(0, 8)}`}
</a>
) : (
<div className="mt-1 font-mono text-xs text-muted-foreground">
{row.poNumber || "No PO"}
</div>
)}
<div className="mt-1 text-base font-semibold text-primary">
{getPoolAgeLabel(row.updated)}
</div>
@ -202,6 +239,17 @@ export function PoolRow({
</div>
)}
{assignedDateLabel && <div className="mt-1 text-xs text-muted-foreground">Scheduled {assignedDateLabel}</div>}
{/* 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 && (
<div className="mt-1 text-xs text-muted-foreground">
Driver: <span className="font-medium text-foreground">{row.driverName}</span>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 lg:flex-col lg:items-start">

View file

@ -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<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] })[]

View file

@ -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", () => {

View file

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