feat(collection-pool): add page and service function

Restores the /collection-pool feature in this codebase.

- src/lib/server/orders-service.ts
  - Re-adds `listCollectionPool({ status, page, perPage })` which
    queries the `collection_pool` PB collection via the admin
    client (filter on pool_status, sort -created, expand
    order_item/order/supplier/drv_id). MCP route.ts already
    imported this — without it, the MCP route failed to
    compile.

- src/app/(app)/collection-pool/{page,components,view-model}*.tsx
  - Re-creates the page directory (5 files, 2 315 lines) carrying
    the supplier-group + per-stop checkbox UI, the
    `CollectionPoolRowSource = "customer" | "stock"` union, the
    assignment-state badge helpers (buildProximityMarkerMap,
    buildSupplierGroups, buildVisibleRows), and the
    MarkReadyDialog / AssignDriverDialog wiring.

- src/app/api/mcp/route.test.ts
  - Adds `const listCollectionPool = vi.fn()` and exposes it in
    the vi.mock factory so the existing collection_pool.list test
    cases (and the bulk_update_quote_items ones that call
    `.mockReset()` on every fixture) stop crashing with
    'ReferenceError: listCollectionPool is not defined'.

Not in this commit (kept narrow per request):
- The /api/v1/collection-pool route handler and its test
- The 'Collection Pool' entry in the sidebar nav
- The migration script that creates the live `collection_pool`
  PB collection (it doesn't exist on the cloud PB instance
  yet; the page render relies on it being created separately
  or via `npm run pb:migrate:orders-v2`)
This commit is contained in:
SJCoetz 2026-07-27 15:04:30 +02:00
parent 72fede1891
commit 811493bedb
7 changed files with 2333 additions and 0 deletions

View file

@ -0,0 +1,468 @@
"use client";
import Link from "next/link";
import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, Truck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { OrderItem } from "@/lib/types";
import {
type CollectionPoolGroup,
getAssignmentBadge,
getAssignmentState,
getPoolAgeLabel,
} from "./view-model";
type CollectionPoolRow = OrderItem & {
ready_for_collection?: boolean | null;
/**
* Discriminator for union rows. `source === "stock"` rows come
* from `stock_purchase_items` and use `stock_line_status` for
* the per-line state machine. The display + actions branch on
* this field — see `PoolRow` for the Mark Collected button.
*/
source?: "customer" | "stock";
/**
* Per-line state for stock rows. One of
* `collection_pool` | `assigned_to_driver` | `collected`. The
* pool page only renders `collection_pool` and
* `assigned_to_driver` (the `collected` rows move to
* collection-confirm). The Mark Collected button shows when
* this is `assigned_to_driver`.
*/
stock_line_status?: string | null;
expand?: {
purchase_order?: {
id: string;
po_number?: string;
supplier?: string;
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
status?: string;
};
order?: {
id: string;
order_number?: string;
company?: string;
customer?: string;
expand?: {
company?: { id: string; company?: string };
customer?: { id: string; customer?: string };
};
};
};
};
export type CollectionPoolVisibleRow = CollectionPoolRow & {
supplierId: string;
supplierName: string;
companyName: string;
customerName: string;
poNumber: string;
orderNumber: string;
};
function markerTokenToClassName(token?: string): string {
switch (token) {
case "marker-blue":
return "bg-blue-500";
case "marker-emerald":
return "bg-emerald-500";
case "marker-violet":
return "bg-violet-500";
case "marker-amber":
return "bg-amber-500";
default:
return "bg-slate-300";
}
}
export function PoolRow({
row,
busy,
markerToken,
todayYmd,
selected,
onToggleSelected,
onOpenAssign,
onToggleReady,
onMarkStockCollected,
}: {
row: CollectionPoolVisibleRow;
busy: boolean;
markerToken?: string;
todayYmd: string;
selected: boolean;
onToggleSelected: (rowId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
onMarkStockCollected: (row: CollectionPoolVisibleRow) => void;
}) {
const assignmentState = getAssignmentState(row, todayYmd);
const assignmentBadge = getAssignmentBadge(assignmentState);
const isAssigned = assignmentState !== "unassigned";
const readyLabel = row.ready_for_collection ? "Ready" : "Pending";
const assignedDateLabel = row.assigned_at
? new Date(row.assigned_at).toLocaleDateString(undefined, {
weekday: "short",
day: "numeric",
month: "short",
})
: null;
// Stock rows hide the customer/order context (it's empty for
// stock lines) and surface the PO + quantity prominently. The
// "Mark Ready" / "Mark Pending" button is also hidden — stock
// lines are simply "in the pool" until a driver is assigned.
// The Mark Collected button shows once the line is in
// `assigned_to_driver` so the operator can flip it to
// `collected` once the driver returns.
const isStock = row.source === "stock";
const showMarkCollected = isStock && row.stock_line_status === "assigned_to_driver";
return (
<li
className={cn(
"grid grid-cols-[6px_minmax(0,1fr)]",
selected && "bg-primary/5",
)}
aria-busy={busy}
>
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-2">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelected(row.id)}
disabled={busy}
aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`}
className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground inline-flex items-center gap-2">
<span>{row.description || "(no description)"}</span>
{/* Stock-source badge. Same primary-color treatment as
collection-confirm so the operator sees at a
glance which rows are supplier pickups vs. customer
deliveries. Hidden for customer rows. */}
{isStock && (
<span
data-od-id="row-stock-badge"
className="inline-flex items-center gap-1 rounded-md bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
Stock
</span>
)}
{isStock && row.order_qty != null && (
<span className="text-xs font-normal text-muted-foreground">
· Qty {row.order_qty}
</span>
)}
</p>
{/* Customer/company context — only relevant for customer
rows. Stock rows show "No customer / No company"
otherwise, which is misleading. */}
{!isStock && (
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{row.customerName || "No customer"}</span>
<span className="text-border">/</span>
<span>{row.companyName || "No company"}</span>
</div>
)}
{!isStock && !row.purchase_order && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200/80 bg-amber-50/70 px-2.5 py-2 text-[11px] leading-5 text-amber-900">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>This row has no purchase order link. The PO may have been deleted.</span>
</div>
)}
</div>
</div>
<div className="min-w-0 text-sm">
{!isStock && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Order</span>
<Link href={`/orders/${row.order}`} className="min-w-0 truncate text-foreground hover:underline">
{row.orderNumber || row.order}
</Link>
</div>
)}
<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>
{row.updated && (
<div className="mt-0.5 text-[11px] text-muted-foreground">
In pool since {new Date(row.updated).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
})}
</div>
)}
{assignedDateLabel && <div className="mt-1 text-xs text-muted-foreground">Scheduled {assignedDateLabel}</div>}
</div>
<div className="flex flex-wrap items-center gap-2 lg:flex-col lg:items-start">
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
{assignmentBadge.label}
</span>
{/* Ready/pending badge is customer-only — stock lines
don't have a `ready_for_collection` concept. */}
{!isStock && (
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60"
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
)}
>
{readyLabel}
</span>
)}
{!isStock && row.notes && row.notes.trim().length > 0 && (
<span className="group/notes relative inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Notes
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-20 mt-1 hidden w-72 rounded-md border bg-popover px-3 py-2 text-xs font-normal normal-case tracking-normal text-popover-foreground shadow-md group-hover/notes:block group-focus-within/notes:block"
>
{row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
</span>
</span>
)}
</div>
<div className="flex flex-col items-stretch gap-2 lg:min-w-[11rem]">
<Button
size="sm"
variant={isAssigned ? "outline" : "default"}
onClick={() => onOpenAssign(row)}
disabled={busy || (!isStock && !row.ready_for_collection)}
title={
!isStock && !row.ready_for_collection
? "Mark this stop as ready before assigning"
: undefined
}
>
{isAssigned ? "Reschedule" : "Assign Driver"}
</Button>
{/* Ready toggle only applies to customer rows. Stock
rows skip straight to the assign action. */}
{!isStock && (
<Button
size="sm"
variant={row.ready_for_collection ? "outline" : "default"}
onClick={() => onToggleReady(row, !row.ready_for_collection)}
disabled={busy}
className={
row.ready_for_collection
? undefined
: "border-emerald-600 bg-emerald-600 text-white shadow-sm shadow-emerald-900/10 hover:border-emerald-700 hover:bg-emerald-700"
}
>
{row.ready_for_collection ? "Mark Pending" : "Mark Ready"}
</Button>
)}
{/* Stock-only: once the driver is assigned, the operator
clicks "Mark Collected" when the driver returns with
the goods. That flips the line from
`assigned_to_driver` to `collected`, which moves it
from this list into the collection-confirm queue for
book-to-stock. */}
{showMarkCollected && (
<Button
size="sm"
variant="outline"
onClick={() => onMarkStockCollected(row)}
disabled={busy}
data-od-id="row-mark-collected"
>
<CheckCircle2 className="mr-1.5 size-3.5" />
Mark Collected
</Button>
)}
{busy && (
<span className="inline-flex items-center justify-end gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
Updating...
</span>
)}
</div>
</div>
</li>
);
}
export function SupplierGroup({
group,
busy,
todayYmd,
selectedIds,
collapsed,
onToggleRowSelected,
onToggleSectionSelected,
onToggleCollapsed,
onOpenAssign,
onOpenBulkAssign,
onToggleReady,
onMarkStockCollected,
}: {
// `group.rows` is typed as `CollectionPoolViewRow[]` in the helper, but
// `PoolRow` (this page's existing row component) expects the richer
// `CollectionPoolVisibleRow` (which extends `OrderItem`). The widening is
// sound because `buildSupplierGroups` is fed `CollectionPoolVisibleRow[]`
// — see Task 7 wiring.
group: Omit<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] };
busy: boolean;
todayYmd: string;
selectedIds: Set<string>;
collapsed: boolean;
onToggleRowSelected: (rowId: string) => void;
onToggleSectionSelected: (rowIds: string[]) => void;
onToggleCollapsed: (supplierId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
/**
* Bulk-assign opener. Accepts the actual row objects (not just
* ids) so the parent can capture the per-row `source` for the
* write loop — customer rows go to `order_items`, stock rows go
* to `stock_purchase_items`.
*/
onOpenBulkAssign: (supplierId: string, supplierName: string, rows: CollectionPoolVisibleRow[]) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
onMarkStockCollected: (row: CollectionPoolVisibleRow) => void;
}) {
const rowIds = group.rows.map((r) => r.id);
const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length;
const allSelected = selectedCount === rowIds.length && rowIds.length > 0;
const someSelected = selectedCount > 0 && !allSelected;
// For stock rows, `getAssignmentState` reads the union row's
// `drv_id` / `assigned_at` — both populated by the assign-driver
// PATCH. The state machine maps identically (drv_id present =
// assigned), so the same badge works for both sources.
const allAssigned = group.rows.every(
(r) => getAssignmentState(r, todayYmd) !== "unassigned",
);
// Compute which rows WOULD be assigned if the user clicked the bulk
// assign button. For customer rows the gate is "ready"; for stock
// rows the gate is just "unassigned" (no ready toggle exists).
const targetRows = allSelected || selectedCount === 0
? group.rows
: group.rows.filter((r) => selectedIds.has(r.id));
const unreadyTargetCount = targetRows.filter(
(r) => r.source !== "stock" && !r.ready_for_collection,
).length;
const allTargetsReady = unreadyTargetCount === 0;
const eyebrowText = (() => {
if (group.topPoNumbers.length === 0) return null;
const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", ");
return group.remainingPoCount > 0
? `${head} (+${group.remainingPoCount} more)`
: head;
})();
const assignLabel = allSelected
? `Assign ${rowIds.length} stops`
: selectedCount > 0
? `Assign ${selectedCount} selected`
: `Assign all ${rowIds.length}`;
const assignDisabledReason = allAssigned
? "Every stop at this supplier is already assigned"
: !allTargetsReady
? `${unreadyTargetCount} ${unreadyTargetCount === 1 ? "stop" : "stops"} not marked ready — mark ready before assigning`
: undefined;
const onAssignClick = () => {
const targetRowList = allSelected || selectedCount === 0
? group.rows
: group.rows.filter((r) => selectedIds.has(r.id));
onOpenBulkAssign(group.supplierId, group.supplierName, targetRowList);
};
return (
<section
aria-labelledby={`supplier-${group.supplierId}`}
data-od-id={`collection-pool-supplier-${group.supplierId}`}
className="border-b last:border-b-0"
>
<header className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5">
<span
className={cn("size-2.5 shrink-0 rounded-full", markerTokenToClassName(group.markerToken))}
aria-hidden
/>
<h3
id={`supplier-${group.supplierId}`}
className="text-sm font-semibold text-foreground"
>
{group.supplierName || "Unknown supplier"}
</h3>
<span className="text-xs text-muted-foreground">
· {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"}
{eyebrowText ? <> · {eyebrowText}</> : null}
</span>
<div className="ml-auto flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() => onToggleSectionSelected(rowIds)}
disabled={busy}
className="size-4 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`Select all ${group.rows.length} stops at ${group.supplierName || "unknown supplier"}`}
/>
Select all
</label>
<Button
size="sm"
onClick={onAssignClick}
disabled={busy || allAssigned || !allTargetsReady}
title={assignDisabledReason}
>
<Truck className="mr-1.5 size-3.5" />
{assignLabel}
</Button>
<button
type="button"
onClick={() => onToggleCollapsed(group.supplierId)}
aria-expanded={!collapsed}
aria-controls={`supplier-${group.supplierId}-body`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronDown className={cn("size-4 transition-transform", collapsed && "-rotate-90")} />
</button>
</div>
</header>
<div
id={`supplier-${group.supplierId}-body`}
className={cn(
"grid overflow-hidden transition-[grid-template-rows] duration-150 ease-out",
collapsed ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
)}
>
<ul className="divide-y min-h-0">
{group.rows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busy}
markerToken={group.markerToken}
todayYmd={todayYmd}
selected={selectedIds.has(row.id)}
onToggleSelected={onToggleRowSelected}
onOpenAssign={onOpenAssign}
onToggleReady={onToggleReady}
onMarkStockCollected={onMarkStockCollected}
/>
))}
</ul>
</div>
</section>
);
}

View file

@ -0,0 +1,364 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CollectionPoolPage from "./page";
const {
getFullListMock,
getMappableSuppliersMock,
toastErrorMock,
toastSuccessMock,
} = vi.hoisted(() => ({
getFullListMock: vi.fn(),
getMappableSuppliersMock: vi.fn(),
toastErrorMock: vi.fn(),
toastSuccessMock: vi.fn(),
}));
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
success: toastSuccessMock,
},
}));
vi.mock("@/lib/pocketbase", () => ({
getPocketBase: () => ({
collection: (name: string) => {
// The page's `load()` now reads from BOTH `order_items`
// (customer deliveries) and `stock_purchase_items` (supplier
// pickups). Test data is all customer rows; the stock side
// returns an empty list so the union query doesn't try to
// render anything for stock.
if (name === "order_items") {
return { getFullList: getFullListMock, update: vi.fn() };
}
if (name === "stock_purchase_items") {
return { getFullList: vi.fn(async () => []), update: vi.fn() };
}
throw new Error(`Unexpected collection: ${name}`);
},
}),
}));
vi.mock("@/lib/pb-helpers", () => ({
getMappableSuppliers: getMappableSuppliersMock,
}));
vi.mock("@/components/ui/assign-driver-dialog", () => ({
AssignDriverDialog: () => null,
}));
function makeRow({
id,
supplierId,
supplierName,
orderNumber,
itemStatus,
driverId,
assignedAt,
}: {
id: string;
supplierId: string;
supplierName: string;
orderNumber: string;
itemStatus: string;
driverId?: string;
assignedAt?: string;
}) {
return {
id,
order: `order-${id}`,
purchase_order: `po-${id}`,
description: `${supplierName} item`,
updated: "2026-07-05T08:00:00.000Z",
item_status: itemStatus,
drv_id: driverId ?? "",
assigned_at: assignedAt ?? "",
ready_for_collection: false,
expand: {
purchase_order: {
id: `po-${id}`,
po_number: `PO-${id}`,
supplier: supplierId,
expand: {
supplier: {
id: supplierId,
suppliername: supplierName,
},
},
},
order: {
id: `order-${id}`,
order_number: orderNumber,
expand: {
company: { id: `company-${id}`, company: "Anglo" },
customer: { id: `customer-${id}`, customer: "North Pit" },
},
},
},
};
}
function getRenderedSupplierOrder() {
const list = screen.getByRole("list");
return within(list)
.getAllByRole("listitem")
.map((item) => item.textContent ?? "");
}
function makeRowWithOverrides(overrides: Record<string, unknown>) {
return makeRow({
id: "alpha",
supplierId: "sup-alpha",
supplierName: "Alpha Bearings",
orderNumber: "SO-1",
itemStatus: "collection_pool",
...overrides,
});
}
function makeRowWithNotes(notes: string) {
const row = makeRowWithOverrides({});
return { ...row, notes };
}
describe("CollectionPoolPage supplier groups + notes", () => {
beforeEach(() => {
getFullListMock.mockResolvedValue([
makeRow({
id: "alpha",
supplierId: "sup-alpha",
supplierName: "Alpha Bearings",
orderNumber: "SO-1",
itemStatus: "collection_pool",
}),
makeRow({
id: "bravo",
supplierId: "sup-bravo",
supplierName: "Bravo Industrial",
orderNumber: "SO-2",
itemStatus: "collection_pool",
}),
makeRow({
id: "charlie",
supplierId: "sup-charlie",
supplierName: "Charlie Mining",
orderNumber: "SO-3",
itemStatus: "collection_pool",
}),
]);
getMappableSuppliersMock.mockResolvedValue([
{ id: "sup-alpha", coordinates: "-26.2041, 28.0473" },
{ id: "sup-bravo", coordinates: "-26.3000, 28.1000" },
{ id: "sup-charlie", coordinates: "-26.2048, 28.0479" },
]);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
}) as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders time-in-list with text-primary class", async () => {
render(<CollectionPoolPage />);
const ageLabels = await screen.findAllByText(/^\d+[hd]$/);
expect(ageLabels.length).toBeGreaterThan(0);
for (const label of ageLabels) {
expect(label.className).toMatch(/text-primary/);
}
});
it("surfaces the Notes tooltip on row hover", async () => {
// Override first mock row to include notes text — only rows with notes render the chip.
getFullListMock.mockResolvedValueOnce([
makeRowWithNotes("Spoke to David — confirmed pickup tomorrow"),
]);
render(<CollectionPoolPage />);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent(/Spoke to David/);
// Tooltip uses the `hidden` class by default; shown only on group-hover/focus-within.
expect(tooltip.className).toMatch(/hidden/);
});
it("applies a primary-tint background to selected rows", async () => {
render(<CollectionPoolPage />);
const firstCheckbox = await screen.findByRole("checkbox", {
name: /Select .* at .*/i,
});
fireEvent.click(firstCheckbox);
const row = firstCheckbox.closest("li");
expect(row).not.toBeNull();
expect(row!.className).toMatch(/bg-primary\/5/);
});
it("section Select all toggles every row in the supplier group", async () => {
render(<CollectionPoolPage />);
const selectAll = await screen.findByRole("checkbox", {
name: /Select all \d+ stops at/i,
});
fireEvent.click(selectAll);
const rowCheckboxes = await screen.findAllByRole("checkbox", {
name: /^Select .* at/i,
});
expect(rowCheckboxes.length).toBeGreaterThan(0);
for (const cb of rowCheckboxes) {
expect(cb).toBeChecked();
}
});
it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => {
render(<CollectionPoolPage />);
const selectAll = await screen.findByRole("checkbox", {
name: /Select all \d+ stops at/i,
});
fireEvent.click(selectAll);
// After toggling section select all, the assign button label becomes
// `Assign N stops` (where N = group rows.length).
const assignAll = await screen.findByRole("button", {
name: /Assign \d+ stops$/,
});
fireEvent.click(assignAll);
// The AssignDriverDialog component is mocked to render `null` in this test
// file (see top of file), so the actual dialog content isn't in the DOM.
// We assert the click handler ran by checking that the section's assign
// button still exists / is reachable, and that row checkboxes remain
// checked (state preserved across the dialog mount).
const rowCheckboxes = screen.getAllByRole("checkbox", {
name: /^Select .* at/i,
});
expect(rowCheckboxes.length).toBeGreaterThan(0);
for (const cb of rowCheckboxes) {
expect(cb).toBeChecked();
}
// Sanity: the button label reflects the bulk-assign state — when all rows
// in a section are selected the label switches from "Assign all N" to
// "Assign N stops".
expect(assignAll.textContent ?? "").toMatch(/Assign \d+ stops/);
});
it("collapses a supplier section on chevron click", async () => {
render(<CollectionPoolPage />);
const section = document.querySelector('[aria-labelledby^="supplier-"]');
expect(section).not.toBeNull();
const headerId = section!.querySelector("header")!.id;
const body = document.getElementById(`${headerId}-body`);
expect(body).not.toBeNull();
// Default (expanded) state: grid-rows-[1fr].
expect(body!.className).toMatch(/grid-rows-\[1fr\]/);
const chevron = section!.querySelector(
'button[aria-expanded]',
) as HTMLButtonElement | null;
expect(chevron).not.toBeNull();
fireEvent.click(chevron!);
// After click: the section body collapses to grid-rows-[0fr].
expect(body!.className).toMatch(/grid-rows-\[0fr\]/);
});
});
describe("CollectionPoolPage sort control", () => {
beforeEach(() => {
getFullListMock.mockResolvedValue([
makeRow({
id: "bravo",
supplierId: "sup-bravo",
supplierName: "Bravo Industrial",
orderNumber: "SO-2",
itemStatus: "collection_pool",
}),
makeRow({
id: "alpha",
supplierId: "sup-alpha",
supplierName: "Alpha Bearings",
orderNumber: "SO-1",
itemStatus: "assigned",
driverId: "drv-1",
assignedAt: "2026-07-07T00:00:00.000Z",
}),
makeRow({
id: "charlie",
supplierId: "sup-charlie",
supplierName: "Charlie Mining",
orderNumber: "SO-3",
itemStatus: "assigned",
driverId: "drv-2",
assignedAt: "2026-07-08T00:00:00.000Z",
}),
]);
getMappableSuppliersMock.mockResolvedValue([
{ id: "sup-alpha", coordinates: "-26.2041, 28.0473" },
{ id: "sup-charlie", coordinates: "-26.2048, 28.0479" },
{ id: "sup-bravo", coordinates: "-26.3000, 28.1000" },
]);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
}) as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("defaults to operational sort and reorders rows when nearby suppliers is selected", async () => {
render(<CollectionPoolPage />);
await screen.findByText("Alpha Bearings");
const operationalButton = screen.getByRole("radio", { name: "Operational" });
const nearbyButton = screen.getByRole("radio", { name: "Nearby suppliers" });
expect(operationalButton.getAttribute("aria-checked")).toBe("true");
expect(nearbyButton.getAttribute("aria-checked")).toBe("false");
await waitFor(() => {
const rows = getRenderedSupplierOrder();
expect(rows[0]).toContain("Alpha Bearings");
expect(rows[1]).toContain("Bravo Industrial");
expect(rows[2]).toContain("Charlie Mining");
});
fireEvent.click(nearbyButton);
await waitFor(() => {
expect(operationalButton.getAttribute("aria-checked")).toBe("false");
expect(nearbyButton.getAttribute("aria-checked")).toBe("true");
const rows = getRenderedSupplierOrder();
expect(rows[0]).toContain("Alpha Bearings");
expect(rows[1]).toContain("Charlie Mining");
expect(rows[2]).toContain("Bravo Industrial");
});
});
});

View file

@ -0,0 +1,890 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search, Truck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
AssignDriverDialog,
type AssignDriverSubmitPayload,
} from "@/components/ui/assign-driver-dialog";
import { MarkReadyDialog } from "@/components/ui/mark-ready-dialog";
import { parseCoordinates } from "@/lib/geo";
import { getPocketBase } from "@/lib/pocketbase";
import { getMappableSuppliers } from "@/lib/pb-helpers";
import type { OrderItem } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
buildProximityMarkerMap,
buildSupplierGroups,
buildVisibleRows,
type CollectionPoolGroup,
type CollectionPoolSortMode,
getAssignmentBadge,
getAssignmentState,
getPoolAgeLabel,
} from "./view-model";
import { PoolRow, SupplierGroup, type CollectionPoolVisibleRow } from "./components";
/**
* Pool row source. The collection-pool page now reads from BOTH
* `order_items` (customer deliveries, `item_status = "collection_pool"`
* or "assigned") and `stock_purchase_items` (supplier pickups,
* `status = "collection_pool"` or "assigned_to_driver"). The two
* have different field names + state machines so we normalise
* to a `CollectionPoolRow` with a `source` discriminator.
*/
type CollectionPoolRowSource = "customer" | "stock";
type CollectionPoolRow = Partial<Omit<OrderItem, "item_status" | "assigned_at">> & {
/** Row id (PB). Always set for both sources. */
id: string;
/**
* Per-row status. Customer rows use the `ItemStatus` union
* (`collection_pool` | `assigned` | etc.); stock rows use the
* `StockPurchaseLineStatus` union (`collection_pool` |
* `assigned_to_driver` | ...). The two string sets overlap on
* the unassigned value but otherwise diverge — typed as a
* permissive string here so the union row shape accepts both.
* `getAssignmentState` reads this for badge logic; it checks
* `=== "assigned"`, which stock rows will never hit, so the
* assignment state for stock rows is driven by `drv_id` only.
*/
item_status?: string;
/** Which collection this row came from. */
source?: CollectionPoolRowSource;
/**
* Stock-only: PB id of the parent `stock_purchase`. Always set
* for `source: "stock"` rows; null/undefined for customer rows.
*/
stock_purchase_id?: string | null;
/**
* Stock-only: the per-line `status` value at fetch time. Possible
* values: `collection_pool` | `assigned_to_driver` | `collected` |
* `booked`. We only fetch `collection_pool` and `assigned_to_driver`
* here — `collected` moves to collection-confirm.
*/
stock_line_status?: string | null;
/**
* Customer-only: supplier-ready flag (set via the
* "Mark Ready" button). Stock rows don't have this.
*/
ready_for_collection?: boolean | null;
expand?: {
purchase_order?: {
id: string;
po_number?: string;
supplier?: string;
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
status?: string;
};
order?: {
id: string;
order_number?: string;
company?: string;
customer?: string;
expand?: {
company?: { id: string; company?: string };
customer?: { id: string; customer?: string };
};
};
};
};
type CollectionPoolAssignDialogState = {
row: CollectionPoolVisibleRow;
initialDriverId: string;
initialDriverLabel?: string;
initialDate?: string;
intent: "assign" | "reschedule";
};
function assignedAtToYmd(iso: string | undefined | null): string | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return d.toISOString().slice(0, 10);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-16 text-center">
<Truck className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
export default function CollectionPoolPage() {
const [rows, setRows] = useState<CollectionPoolRow[] | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [filterText, setFilterText] = useState("");
const [readyFilter, setReadyFilter] = useState<"all" | "ready" | "unready">("all");
const [sortMode, setSortMode] = useState<CollectionPoolSortMode>("operational");
const [supplierCoords, setSupplierCoords] = useState<Map<string, { lat: number; lng: number }> | null>(null);
const supFetchPromise = useRef<Promise<void> | null>(null);
const [driversById, setDriversById] = useState<Record<string, string>>({});
const [assignDialog, setAssignDialog] = useState<CollectionPoolAssignDialogState | null>(null);
const [readyDialogRow, setReadyDialogRow] = useState<CollectionPoolVisibleRow | null>(null);
const [readyDialogError, setReadyDialogError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [collapsedSuppliers, setCollapsedSuppliers] = useState<Set<string>>(new Set());
const [bulkAssignDialog, setBulkAssignDialog] = useState<{
supplierId: string;
supplierName: string;
rowIds: string[];
/** Map of rowId → source captured at dialog-open time. Drives
* the write path in `handleBulkAssignSubmit` (customer →
* `order_items`, stock → `stock_purchase_items`). */
sourceByRowId: Record<string, CollectionPoolRowSource>;
} | null>(null);
const toggleRowSelected = useCallback((rowId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(rowId)) next.delete(rowId);
else next.add(rowId);
return next;
});
}, []);
const toggleSectionSelected = useCallback((rowIds: string[]) => {
setSelectedIds((prev) => {
const allSelected = rowIds.every((id) => prev.has(id));
const next = new Set(prev);
if (allSelected) {
for (const id of rowIds) next.delete(id);
} else {
for (const id of rowIds) next.add(id);
}
return next;
});
}, []);
const toggleCollapsed = useCallback((supplierId: string) => {
setCollapsedSuppliers((prev) => {
const next = new Set(prev);
if (next.has(supplierId)) next.delete(supplierId);
else next.add(supplierId);
return next;
});
}, []);
const openBulkAssign = useCallback(
(supplierId: string, supplierName: string, rows: CollectionPoolVisibleRow[]) => {
if (rows.length === 0) return;
const rowIds = rows.map((r) => r.id);
// Capture the source per row so the assign loop in
// `handleBulkAssignSubmit` knows which collection to write
// to (customer → `order_items`, stock → `stock_purchase_items`).
const sourceByRowId: Record<string, CollectionPoolRowSource> = {};
for (const r of rows) {
sourceByRowId[r.id] = (r.source ?? "customer") as CollectionPoolRowSource;
}
setBulkAssignDialog({ supplierId, supplierName, rowIds, sourceByRowId });
},
[],
);
const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []);
const closeAssignDialog = useCallback(() => setAssignDialog(null), []);
const openAssignForRow = useCallback((row: CollectionPoolVisibleRow) => {
const driverLabel = row.drv_id ? driversById[row.drv_id] ?? `Driver ${row.drv_id.slice(0, 8)}` : undefined;
setAssignDialog({
row,
initialDriverId: row.drv_id ?? "",
initialDriverLabel: driverLabel,
initialDate: assignedAtToYmd(row.assigned_at),
intent: getAssignmentState(row, new Date().toISOString().slice(0, 10)) === "unassigned" ? "assign" : "reschedule",
});
}, [driversById]);
const load = useCallback(async () => {
setRows(null);
try {
const pb = getPocketBase();
// Union query: customer deliveries from `order_items`
// (`item_status = "collection_pool" || "assigned"`) UNION
// supplier pickups from `stock_purchase_items`
// (`status = "collection_pool" || "assigned_to_driver"`).
//
// The two state machines don't share field names so we
// normalise stock rows into the same `CollectionPoolRow`
// shape with a `source` discriminator. Downstream code
// branches on `row.source` for write actions (assign,
// collect) — customer writes go to `order_items`, stock
// writes go to `stock_purchase_items`.
const [customerRows, stockRows] = await Promise.all([
pb.collection("order_items").getFullList<CollectionPoolRow>({
filter: 'item_status = "collection_pool" || item_status = "assigned"',
sort: "updated",
expand: "purchase_order.supplier,order.company,order.customer",
}),
pb
.collection("stock_purchase_items")
.getFullList<
CollectionPoolRow & {
id: string;
stock_item: string;
quantity: number;
status?: string | null;
assigned_at?: string | null;
expand?: {
purchase?: {
id: string;
purchase_number: string;
expand?: {
supplier?: { id: string; suppliername?: string; email?: string };
};
};
stock_item?: { id: string; sku?: string; name?: string };
};
}
>({
filter: 'status = "collection_pool" || status = "assigned_to_driver"',
sort: "-created",
expand: "purchase.supplier,purchase,stock_item",
})
.catch(() => [] as unknown[]),
]);
// Normalise stock rows into the CollectionPoolRow shape. We
// populate the same fields the customer branch uses
// (po_number from `expand.purchase_order.po_number` etc.) so
// the existing display + assign flow can treat both sources
// uniformly.
const stockAsPool: CollectionPoolRow[] = (
stockRows as unknown as Array<{
id: string;
stock_item: string;
description: string;
quantity: number;
status?: string | null;
assigned_at?: string | null;
drv_id?: string;
expand?: CollectionPoolRow["expand"] & {
purchase?: {
id: string;
purchase_number: string;
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
};
stock_item?: { id: string; sku?: string; name?: string };
};
}>
).map((s) => ({
// Use `s.id` as both the row id and the `id` field so the
// existing display treats it like a normal row.
id: s.id,
// Marker for downstream code that branches on source.
source: "stock" as const,
stock_purchase_id: s.expand?.purchase?.id ?? null,
stock_line_status: s.status ?? null,
// `description` is what the supplier link / order cells read.
description: s.description,
// `order_qty` mirrors `quantity` (no customer_qty split for
// stock — the line is just "we ordered N units").
order_qty: s.quantity,
quantity: s.quantity,
// The customer-style status field stays null for stock rows;
// the assign / collect buttons read `stock_line_status` to
// know which table to write to.
item_status: s.status ?? undefined,
// Carry through the assignment so the "Assigned to ..." badge
// renders correctly when the driver has been assigned.
assigned_at: s.assigned_at ?? null,
// Stock rows don't have a customer-order relation. We
// leave `expand.order` undefined so the display can omit
// the customer/order cells.
expand: {
// Reuse the `purchase_order` field name so the existing
// display's PO link works for both sources.
purchase_order: s.expand?.purchase
? {
id: s.expand.purchase.id,
po_number: s.expand.purchase.purchase_number,
// `supplier` is the supplier relation id, used by
// the existing assign-by-supplier-group logic.
supplier: s.expand.purchase.expand?.supplier?.id,
expand: { supplier: s.expand.purchase.expand?.supplier },
}
: undefined,
},
}));
const customerAsPool: CollectionPoolRow[] = customerRows.map((r) => ({
...r,
source: "customer" as const,
}));
setRows([...customerAsPool, ...stockAsPool]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(message);
setRows([]);
}
}, []);
useEffect(() => {
const handle = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(handle);
}, [load]);
useEffect(() => {
if (typeof window === "undefined") return;
if (supFetchPromise.current) return;
supFetchPromise.current = (async () => {
try {
const suppliers = await getMappableSuppliers();
const next = new Map<string, { lat: number; lng: number }>();
for (const supplier of suppliers) {
const coords = parseCoordinates(supplier.coordinates);
if (!coords || !supplier.id) continue;
next.set(supplier.id, { lat: coords[0], lng: coords[1] });
}
setSupplierCoords(next);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(`Couldn't load supplier locations: ${message}`);
}
})();
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
let cancelled = false;
fetch("/api/v1/users/lookup")
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data: { id: string; name?: string; email?: string }[]) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const user of data) {
if (!user.id) continue;
next[user.id] = user.name || user.email || `Driver ${user.id.slice(0, 8)}`;
}
setDriversById(next);
})
.catch(() => {
// Best-effort only. Reschedule still falls back to the driver id slice.
});
return () => {
cancelled = true;
};
}, []);
const todayYmd = new Date().toISOString().slice(0, 10);
const supplierMarkerMap = useMemo(() => {
if (!supplierCoords) return new Map<string, string>();
return buildProximityMarkerMap(
Array.from(supplierCoords.entries()).map(([supplierId, coord]) => ({
supplierId,
lat: coord.lat,
lng: coord.lng,
})),
2,
);
}, [supplierCoords]);
const clusterSortKeyForSupplier = useCallback(
(supplierId: string) => supplierMarkerMap.get(supplierId) ?? `solo-${supplierId}`,
[supplierMarkerMap],
);
const visibleRows = useMemo(() => {
if (!rows) return null;
return buildVisibleRows(
rows.map((row) => {
// For stock rows, the supplier id sits on
// `expand.purchase_order.supplier` (we set that on the way
// through `load()`). For customer rows, same path. Falls
// back to a sentinel "unknown" so the row still groups.
const supplierId = row.expand?.purchase_order?.supplier ?? "unknown";
return {
...row,
supplierId,
supplierName: row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier",
// Stock rows have no customer/order relation — the
// company/customer cells stay blank. They show PO +
// description + qty, which is enough for the operator.
companyName: row.expand?.order?.expand?.company?.company ?? "",
customerName: row.expand?.order?.expand?.customer?.customer ?? "",
poNumber: row.expand?.purchase_order?.po_number ?? "",
orderNumber: row.expand?.order?.order_number ?? "",
clusterSortKey: clusterSortKeyForSupplier(supplierId),
};
}),
{
todayYmd,
searchText: filterText,
readyFilter,
sortMode,
},
) as CollectionPoolVisibleRow[];
}, [rows, todayYmd, filterText, readyFilter, sortMode, clusterSortKeyForSupplier]);
const groups = useMemo<
| (Omit<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] })[]
| null
>(() => {
if (!visibleRows) return null;
// `buildSupplierGroups` types its rows as `CollectionPoolViewRow[]`,
// but every row in `visibleRows` is a `CollectionPoolVisibleRow` (a
// superset of `CollectionPoolViewRow` — see the cast above). The
// widening per-row is sound.
return buildSupplierGroups(visibleRows, supplierMarkerMap).map((g) => ({
...g,
rows: g.rows as CollectionPoolVisibleRow[],
}));
}, [visibleRows, supplierMarkerMap]);
const updateItemStatus = useCallback(
async (
itemId: string,
patch: Partial<Pick<OrderItem, "item_status" | "drv_id" | "drv_status" | "assigned_at">>,
) => {
setBusyId(itemId);
try {
const pb = getPocketBase();
await pb.collection("order_items").update(itemId, patch);
toast.success(`Item ${itemId.slice(0, 8)} updated.`);
await load();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(message);
} finally {
setBusyId(null);
}
},
[load],
);
// Stock-line write: PATCH to `stock_purchase_items` instead of
// `order_items`. Used by the assign-driver and mark-collected
// handlers when `row.source === "stock"`. The state machine on
// the stock side is `collection_pool` → `assigned_to_driver` →
// `collected` (→ "booked" via the collection-confirm page, not
// here). Note that `assigned_at` is a `date` field on the stock
// side (no time component), so we send just the yyyy-mm-dd.
const updateStockLineStatus = useCallback(
async (
itemId: string,
patch: { status?: string; drv_id?: string; assigned_at?: string | null },
) => {
setBusyId(itemId);
try {
const pb = getPocketBase();
await pb.collection("stock_purchase_items").update(itemId, patch);
toast.success(`Stock line ${itemId.slice(0, 8)} updated.`);
await load();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(message);
} finally {
setBusyId(null);
}
},
[load],
);
const handleAssignDialogSubmit = useCallback(
async (payload: AssignDriverSubmitPayload) => {
const { driverId, date } = payload;
if (!assignDialog) return;
setBusyId(assignDialog.row.id);
try {
if (assignDialog.row.source === "stock") {
// Stock-line assign: writes to `stock_purchase_items`.
// `assigned_at` is a date field (no time component).
await updateStockLineStatus(assignDialog.row.id, {
status: "assigned_to_driver",
drv_id: driverId,
assigned_at: date,
});
} else {
await updateItemStatus(assignDialog.row.id, {
item_status: "assigned",
drv_id: driverId,
drv_status: "assigned",
assigned_at: `${date}T00:00:00.000Z`,
} as Partial<OrderItem>);
}
closeAssignDialog();
} finally {
setBusyId(null);
}
},
[assignDialog, closeAssignDialog, updateItemStatus, updateStockLineStatus],
);
const [bulkBusy, setBulkBusy] = useState(false);
const handleBulkAssignSubmit = useCallback(
async (payload: AssignDriverSubmitPayload) => {
if (!bulkAssignDialog) return;
const { driverId, date } = payload;
const { rowIds, supplierName, sourceByRowId } = bulkAssignDialog;
setBulkBusy(true);
try {
const pb = getPocketBase();
let successCount = 0;
let firstError: string | null = null;
// Sequential, not parallel: any failure leaves earlier
// successes committed (matches the single-row PATCH semantics).
// Source map is captured at dialog-open time so the loop
// doesn't need to re-derive per iteration.
for (const id of rowIds) {
const source = sourceByRowId[id] ?? "customer";
try {
if (source === "stock") {
await pb.collection("stock_purchase_items").update(id, {
status: "assigned_to_driver",
drv_id: driverId,
assigned_at: date,
});
} else {
await pb.collection("order_items").update(id, {
item_status: "assigned",
drv_id: driverId,
drv_status: "assigned",
assigned_at: `${date}T00:00:00.000Z`,
} as Partial<OrderItem>);
}
successCount++;
} catch (err) {
if (!firstError) {
firstError = err instanceof Error ? err.message : String(err);
}
}
}
// Clear selections for this supplier on success (partial or full).
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of rowIds) next.delete(id);
return next;
});
closeBulkAssign();
if (firstError) {
toast.error(
`Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`,
);
} else {
toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`);
}
await load();
} finally {
setBulkBusy(false);
}
},
[bulkAssignDialog, closeBulkAssign, load],
);
// Mark-collected: stock-only flow. Flips a stock line from
// `assigned_to_driver` to `collected`, which moves the row from
// the collection-pool to the collection-confirm queue. The
// operator clicks this once the driver returns with the goods
// from the supplier. The button only renders for stock rows
// (see PoolRow); for customer lines the collected transition
// happens elsewhere (driver app / direct PB write).
const handleMarkStockCollected = useCallback(
async (row: CollectionPoolVisibleRow) => {
if (row.source !== "stock") return;
await updateStockLineStatus(row.id, { status: "collected" });
},
[updateStockLineStatus],
);
const handleToggleReady = useCallback(
async (row: CollectionPoolVisibleRow, ready: boolean) => {
// Unmarking is a single PATCH with no note. The modal only opens
// for the "mark ready" direction.
if (!ready) {
setBusyId(row.id);
try {
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ready_for_collection: false }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
toast.success("Marked pending supplier confirmation.");
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusyId(null);
}
return;
}
// Marking ready opens the modal. The actual PATCH is fired from
// `submitMarkReady` once the operator has typed a note.
setReadyDialogError(null);
setReadyDialogRow(row);
},
[load],
);
const submitMarkReady = useCallback(
async (note: string) => {
const row = readyDialogRow;
if (!row) return;
setBusyId(row.id);
setReadyDialogError(null);
try {
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ready_for_collection: true,
ready_notes: note,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = body?.error || `HTTP ${res.status}`;
// Keep the modal open so the operator can fix the error.
setReadyDialogError(message);
throw new Error(message);
}
toast.success("Marked ready — note saved.");
setReadyDialogRow(null);
await load();
} catch (err) {
// Error already shown via readyDialogError / toast.
console.error("Mark ready failed:", err);
} finally {
setBusyId(null);
}
},
[load, readyDialogRow],
);
return (
<div className="space-y-6">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Truck className="h-5 w-5 text-muted-foreground" />
Collection Pool
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Order items awaiting driver pickup in one operational list. Assign, reschedule, and update readiness from here.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load()}>
Refresh
</Button>
</div>
</header>
<div className="rounded-xl border bg-card">
<div className="flex flex-col gap-3 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3 rounded-lg border bg-background px-3 py-2 lg:min-w-[22rem] lg:max-w-xl lg:flex-1">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<input
type="search"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search supplier, item, PO, or order..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
aria-label="Search collection pool"
/>
{filterText && (
<button
type="button"
onClick={() => setFilterText("")}
className="text-xs font-medium text-muted-foreground hover:text-foreground"
>
Clear
</button>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-3 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium uppercase tracking-wide">Sort</span>
<div
role="radiogroup"
aria-label="Sort collection pool"
className="inline-flex rounded-md border bg-card shadow-sm"
>
{([
{ id: "operational", label: "Operational" },
{ id: "nearby", label: "Nearby suppliers" },
] as const).map((opt) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={sortMode === opt.id}
onClick={() => setSortMode(opt.id)}
className={cn(
"px-3 py-1 text-xs transition-colors",
sortMode === opt.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium uppercase tracking-wide">Ready</span>
<div
role="radiogroup"
aria-label="Filter by ready-for-collection status"
className="inline-flex rounded-lg border bg-background p-1"
>
{(["all", "ready", "unready"] as const).map((opt) => (
<button
key={opt}
type="button"
role="radio"
aria-checked={readyFilter === opt}
onClick={() => setReadyFilter(opt)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
readyFilter === opt
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{opt === "all" ? "All" : opt === "ready" ? "Ready to collect" : "Pending"}
</button>
))}
</div>
</div>
</div>
</div>
</div>
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] gap-4 border-b bg-muted/20 px-4 py-2 pl-[22px] text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground lg:grid">
<span>Collection Item</span>
<span>Order Detail</span>
<span>Status</span>
<span className="text-right">Actions</span>
</div>
{groups === null ? (
<div className="space-y-2 p-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-20 w-full animate-pulse rounded-md bg-muted/30"
aria-label="Loading..."
/>
))}
</div>
) : groups.length === 0 ? (
<EmptyState message="No items match the current filters." />
) : (
groups.map((group) => (
<SupplierGroup
key={group.supplierId}
group={group}
busy={busyId !== null}
todayYmd={todayYmd}
selectedIds={selectedIds}
collapsed={collapsedSuppliers.has(group.supplierId)}
onToggleRowSelected={toggleRowSelected}
onToggleSectionSelected={toggleSectionSelected}
onToggleCollapsed={toggleCollapsed}
onOpenAssign={openAssignForRow}
onOpenBulkAssign={openBulkAssign}
onToggleReady={handleToggleReady}
onMarkStockCollected={handleMarkStockCollected}
/>
))
)}
</div>
<AssignDriverDialog
open={assignDialog !== null}
onOpenChange={(open) => {
if (!open) closeAssignDialog();
}}
onSubmit={handleAssignDialogSubmit}
intent={assignDialog?.intent}
initialDriverId={assignDialog?.initialDriverId ?? ""}
initialDriverLabel={assignDialog?.initialDriverLabel}
initialDate={assignDialog?.initialDate}
busy={assignDialog ? busyId === assignDialog.row.id : false}
bulkCount={1}
eyebrow={
assignDialog ? (
<span className="inline-flex items-center gap-2">
<Truck className="size-3.5 text-muted-foreground" />
{assignDialog.row.supplierName}
{assignDialog.row.poNumber ? (
<>
<span className="text-muted-foreground">·</span>
PO {assignDialog.row.poNumber}
</>
) : null}
</span>
) : undefined
}
/>
<AssignDriverDialog
open={bulkAssignDialog !== null}
onOpenChange={(open) => {
if (!open) closeBulkAssign();
}}
onSubmit={handleBulkAssignSubmit}
bulkCount={bulkAssignDialog?.rowIds.length ?? 1}
eyebrow={
bulkAssignDialog ? (
<span className="inline-flex items-center gap-2">
<Truck className="size-3.5 text-muted-foreground" />
{bulkAssignDialog.supplierName || "Unknown supplier"}
<span className="text-muted-foreground">·</span>
{bulkAssignDialog.rowIds.length} stops
</span>
) : undefined
}
busy={bulkBusy}
/>
{readyDialogRow && (
<MarkReadyDialog
open
onOpenChange={(o) => {
if (!o) {
setReadyDialogRow(null);
setReadyDialogError(null);
}
}}
onSubmit={submitMarkReady}
poLabel={readyDialogRow.poNumber}
itemLabel={readyDialogRow.description}
busy={busyId === readyDialogRow.id}
serverError={readyDialogError}
/>
)}
</div>
);
}

View file

@ -0,0 +1,381 @@
import { describe, expect, it } from "vitest";
import {
buildProximityMarkerMap,
buildSupplierGroups,
buildVisibleRows,
type CollectionPoolGroup,
type CollectionPoolSortMode,
getAssignmentBadge,
getAssignmentState,
getPoolAgeLabel,
type CollectionPoolViewRow,
} from "./view-model";
function makeRow(overrides: Partial<CollectionPoolViewRow> = {}): CollectionPoolViewRow {
return {
id: "row-1",
description: "Wheel hub kit",
updated: "2026-07-05T08:00:00.000Z",
item_status: "collection_pool",
drv_id: "",
assigned_at: "",
ready_for_collection: false,
supplierId: "sup-1",
supplierName: "Alpha Bearings",
companyName: "Anglo",
customerName: "North Pit",
poNumber: "PO-1",
...overrides,
};
}
describe("getAssignmentState", () => {
it("returns unassigned when driver fields are empty", () => {
expect(getAssignmentState(makeRow(), "2026-07-07")).toBe("unassigned");
});
it("returns assigned_today when assigned_at lands on today", () => {
expect(
getAssignmentState(
makeRow({
item_status: "assigned",
drv_id: "drv-1",
assigned_at: "2026-07-07T00:00:00.000Z",
}),
"2026-07-07",
),
).toBe("assigned_today");
});
it("returns assigned_future when assigned_at is after today", () => {
expect(
getAssignmentState(
makeRow({
item_status: "assigned",
drv_id: "drv-1",
assigned_at: "2026-07-09T00:00:00.000Z",
}),
"2026-07-07",
),
).toBe("assigned_future");
});
});
describe("getPoolAgeLabel", () => {
it("formats fresh rows in hours and older rows in days", () => {
expect(getPoolAgeLabel("2026-07-07T06:00:00.000Z", new Date("2026-07-07T12:00:00.000Z"))).toBe("6h");
expect(getPoolAgeLabel("2026-07-03T12:00:00.000Z", new Date("2026-07-07T12:00:00.000Z"))).toBe("4d");
});
});
describe("getAssignmentBadge", () => {
it("maps each assignment state to the expected label and token classes", () => {
expect(getAssignmentBadge("unassigned")).toEqual({
label: "Unassigned",
className: "bg-slate-100 text-slate-700 ring-1 ring-slate-200/60",
});
expect(getAssignmentBadge("assigned_today")).toEqual({
label: "Assigned today",
className: "bg-blue-100 text-blue-800 ring-1 ring-blue-200/60",
});
expect(getAssignmentBadge("assigned_future")).toEqual({
label: "Assigned future",
className: "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60",
});
});
});
describe("buildVisibleRows", () => {
it("sorts assigned today first, then unassigned, then future", () => {
const rows = buildVisibleRows(
[
makeRow({
id: "future",
item_status: "assigned",
drv_id: "drv-2",
assigned_at: "2026-07-08T00:00:00.000Z",
}),
makeRow({ id: "unassigned" }),
makeRow({
id: "today",
item_status: "assigned",
drv_id: "drv-1",
assigned_at: "2026-07-07T00:00:00.000Z",
}),
],
{ todayYmd: "2026-07-07", searchText: "", readyFilter: "all" },
);
expect(rows.map((row) => row.id)).toEqual(["today", "unassigned", "future"]);
});
it("keeps assigned rows visible when ready-only filters are active", () => {
const rows = buildVisibleRows(
[
makeRow({
id: "assigned-ready",
item_status: "assigned",
drv_id: "drv-1",
assigned_at: "2026-07-07T00:00:00.000Z",
ready_for_collection: true,
}),
makeRow({ id: "awaiting", ready_for_collection: false }),
],
{ todayYmd: "2026-07-07", searchText: "", readyFilter: "ready" },
);
expect(rows.map((row) => row.id)).toEqual(["assigned-ready"]);
});
it("matches order numbers in the text search", () => {
const rows = buildVisibleRows(
[
Object.assign(makeRow({ id: "order-match" }), { orderNumber: "SO-123" }) as CollectionPoolViewRow,
Object.assign(makeRow({ id: "other" }), { orderNumber: "SO-999" }) as CollectionPoolViewRow,
],
{ todayYmd: "2026-07-07", searchText: "so-123", readyFilter: "all" },
);
expect(rows.map((row) => row.id)).toEqual(["order-match"]);
});
});
describe("buildVisibleRows sort modes", () => {
function sortRows(sortMode: CollectionPoolSortMode) {
return buildVisibleRows(
[
makeRow({ id: "cluster-b-unassigned", clusterSortKey: "cluster-b" }),
makeRow({
id: "cluster-a-today",
clusterSortKey: "cluster-a",
item_status: "assigned",
drv_id: "drv-1",
assigned_at: "2026-07-07T00:00:00.000Z",
}),
makeRow({
id: "cluster-a-future",
clusterSortKey: "cluster-a",
item_status: "assigned",
drv_id: "drv-2",
assigned_at: "2026-07-08T00:00:00.000Z",
}),
],
{ todayYmd: "2026-07-07", searchText: "", readyFilter: "all", sortMode },
);
}
it("keeps operational sort as the default mode", () => {
expect(sortRows("operational").map((row) => row.id)).toEqual([
"cluster-a-today",
"cluster-b-unassigned",
"cluster-a-future",
]);
});
it("sorts by cluster key before operational order in nearby mode", () => {
expect(sortRows("nearby").map((row) => row.id)).toEqual([
"cluster-a-today",
"cluster-a-future",
"cluster-b-unassigned",
]);
});
});
describe("buildProximityMarkerMap", () => {
it("assigns the same marker token to nearby suppliers", () => {
const markers = buildProximityMarkerMap(
[
{ supplierId: "sup-a", lat: -26.2, lng: 28.0 },
{ supplierId: "sup-b", lat: -26.2008, lng: 28.0008 },
{ supplierId: "sup-c", lat: -26.4, lng: 28.4 },
],
2,
);
expect(markers.get("sup-a")).toBe(markers.get("sup-b"));
expect(markers.get("sup-a")).not.toBe(markers.get("sup-c"));
});
});
describe("buildVisibleRows sort modes (nearby pinning)", () => {
const todayYmd = "2026-07-09";
it("operational sort is deterministic on the same input", () => {
const rows: CollectionPoolViewRow[] = [
makeRow({ id: "a", poNumber: "PO-A", updated: "2026-07-02T00:00:00.000Z" }),
makeRow({ id: "b", poNumber: "PO-B", updated: "2026-07-01T00:00:00.000Z" }),
];
const first = buildVisibleRows(rows, {
todayYmd,
searchText: "",
readyFilter: "all",
sortMode: "operational",
});
const second = buildVisibleRows(rows, {
todayYmd,
searchText: "",
readyFilter: "all",
sortMode: "operational",
});
// Same input + same options = same output, every time. The actual
// direction of the sort is determined by assignment state and
// updated timestamp; the value of this test is that the function
// is stable, not that it sorts in a particular direction.
expect(first.map((r) => r.id)).toEqual(second.map((r) => r.id));
expect(first).toHaveLength(2);
});
it("nearby sort groups rows by clusterSortKey ascending", () => {
const rows: CollectionPoolViewRow[] = [
makeRow({ id: "c", clusterSortKey: "cluster-c" }),
makeRow({ id: "a", clusterSortKey: "cluster-a" }),
makeRow({ id: "b", clusterSortKey: "cluster-b" }),
];
const out = buildVisibleRows(rows, {
todayYmd,
searchText: "",
readyFilter: "all",
sortMode: "nearby",
});
expect(out.map((r) => r.id)).toEqual(["a", "b", "c"]);
});
it("nearby sort sinks rows with missing clusterSortKey to the end", () => {
const rows: CollectionPoolViewRow[] = [
makeRow({ id: "no-coords-1", clusterSortKey: undefined }),
makeRow({ id: "a", clusterSortKey: "cluster-a" }),
makeRow({ id: "no-coords-2", clusterSortKey: undefined }),
makeRow({ id: "b", clusterSortKey: "cluster-b" }),
];
const out = buildVisibleRows(rows, {
todayYmd,
searchText: "",
readyFilter: "all",
sortMode: "nearby",
});
// Rows WITH coords come first, ordered by cluster key.
// Rows WITHOUT coords come last (sink to the end).
expect(out.map((r) => r.id)).toEqual(["a", "b", "no-coords-1", "no-coords-2"]);
});
});
describe("buildVisibleRows — supplier-first sort (operational mode)", () => {
const todayYmd = "2026-07-10";
it("sorts by supplierName first when sortMode is operational", () => {
const rows = buildVisibleRows(
[
makeRow({ id: "beta", supplierName: "Beta" }),
makeRow({ id: "acme", supplierName: "Acme" }),
makeRow({ id: "alpha", supplierName: "Alpha" }),
],
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" },
);
expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]);
});
it("falls through to assignmentState within the same supplier", () => {
const rows = buildVisibleRows(
[
makeRow({
id: "acme-unassigned",
supplierName: "Acme",
item_status: "collection_pool",
}),
makeRow({
id: "acme-today",
supplierName: "Acme",
item_status: "assigned",
drv_id: "drv-1",
assigned_at: `${todayYmd}T00:00:00.000Z`,
}),
],
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" },
);
expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]);
});
it("nearby mode still uses cluster prefix before supplier", () => {
const rows = buildVisibleRows(
[
makeRow({
id: "beta-cluster-a",
supplierName: "Beta",
clusterSortKey: "cluster-a",
}),
makeRow({
id: "acme-cluster-b",
supplierName: "Acme",
clusterSortKey: "cluster-b",
}),
makeRow({
id: "acme-cluster-a",
supplierName: "Acme",
clusterSortKey: "cluster-a",
}),
],
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" },
);
expect(rows.map((r) => r.id)).toEqual([
"acme-cluster-a",
"beta-cluster-a",
"acme-cluster-b",
]);
});
});
describe("buildSupplierGroups", () => {
it("returns an empty array for empty input", () => {
expect(buildSupplierGroups([], new Map())).toEqual([]);
});
it("folds rows by supplierId in input order", () => {
const rows = [
makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }),
makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }),
makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }),
];
const groups = buildSupplierGroups(rows, new Map());
expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]);
expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]);
expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]);
});
it("captures the top two PO numbers and reports the remaining count", () => {
const rows = [
makeRow({ supplierId: "sup-1", poNumber: "PO-1" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-2" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-3" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-4" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO
];
const [group] = buildSupplierGroups(rows, new Map());
expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]);
expect(group.remainingPoCount).toBe(2);
});
it("uses marker token from supplierMarkerMap", () => {
const map = new Map([["sup-1", "marker-blue"]]);
const [group] = buildSupplierGroups(
[makeRow({ supplierId: "sup-1" })],
map,
);
expect(group.markerToken).toBe("marker-blue");
});
it("returns empty eyebrow when no rows have a PO number", () => {
const [group] = buildSupplierGroups(
[makeRow({ supplierId: "sup-1", poNumber: "" })],
new Map(),
);
expect(group.topPoNumbers).toEqual([]);
expect(group.remainingPoCount).toBe(0);
});
});

View file

@ -0,0 +1,212 @@
import { buildClusters } from "@/lib/geo";
export type AssignmentState = "unassigned" | "assigned_today" | "assigned_future";
export type AssignmentBadge = {
label: string;
className: string;
};
export type CollectionPoolSortMode = "operational" | "nearby";
export type CollectionPoolViewRow = {
id: string;
/**
* Which collection this row came from. The pool page now
* reads from both `order_items` and `stock_purchase_items`;
* `source: "stock"` rows get a Stock badge and a different
* write target on assign/collect.
*/
source?: "customer" | "stock";
description?: string;
updated?: string;
item_status?: string;
drv_id?: string;
assigned_at?: string;
ready_for_collection?: boolean | null;
/** Stock-only: PB id of the parent `stock_purchase`. */
stock_purchase_id?: string | null;
/** Stock-only: per-line status at fetch time. */
stock_line_status?: string | null;
supplierId: string;
supplierName?: string;
companyName?: string;
customerName?: string;
poNumber?: string;
orderNumber?: string;
clusterSortKey?: string;
};
const ASSIGNMENT_SORT_ORDER: Record<AssignmentState, number> = {
assigned_today: 0,
unassigned: 1,
assigned_future: 2,
};
const MARKER_TOKENS = ["marker-blue", "marker-emerald", "marker-violet", "marker-amber"] as const;
export function getAssignmentState(
row: Pick<CollectionPoolViewRow, "item_status" | "drv_id" | "assigned_at">,
todayYmd: string,
): AssignmentState {
const hasAssignment = Boolean(row.drv_id) || row.item_status === "assigned";
if (!hasAssignment) return "unassigned";
const assignedYmd = row.assigned_at ? row.assigned_at.slice(0, 10) : "";
return assignedYmd > todayYmd ? "assigned_future" : "assigned_today";
}
export function getPoolAgeLabel(updatedAt: string | undefined, now = new Date()): string {
if (!updatedAt) return "-";
const updated = new Date(updatedAt);
if (Number.isNaN(updated.getTime())) return "-";
const diffMs = Math.max(0, now.getTime() - updated.getTime());
const hours = Math.floor(diffMs / 3_600_000);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export function getAssignmentBadge(state: AssignmentState): AssignmentBadge {
switch (state) {
case "assigned_today":
return {
label: "Assigned today",
className: "bg-blue-100 text-blue-800 ring-1 ring-blue-200/60",
};
case "assigned_future":
return {
label: "Assigned future",
className: "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60",
};
default:
return {
label: "Unassigned",
className: "bg-slate-100 text-slate-700 ring-1 ring-slate-200/60",
};
}
}
export function buildVisibleRows(
rows: CollectionPoolViewRow[],
options: {
todayYmd: string;
searchText: string;
readyFilter: "all" | "ready" | "unready";
sortMode?: CollectionPoolSortMode;
},
): CollectionPoolViewRow[] {
const query = options.searchText.trim().toLowerCase();
const sortMode = options.sortMode ?? "operational";
return rows
.filter((row) => {
if (options.readyFilter === "ready" && !row.ready_for_collection) return false;
if (options.readyFilter === "unready" && row.ready_for_collection) return false;
if (!query) return true;
return [
row.supplierName ?? "",
row.description ?? "",
row.poNumber ?? "",
row.orderNumber ?? "",
row.customerName ?? "",
row.companyName ?? "",
].some((value) => value.toLowerCase().includes(query));
})
.sort((a, b) => {
if (sortMode === "nearby") {
const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz");
if (clusterDiff !== 0) return clusterDiff;
}
// Supplier name is the primary group key. Rows in the same
// supplier are then ordered by assignment urgency (today →
// unassigned → future), then by updated, then by PO + description.
const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? "");
if (supplierDiff !== 0) return supplierDiff;
const stateDiff =
ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] -
ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)];
if (stateDiff !== 0) return stateDiff;
const updatedA = a.updated ?? "";
const updatedB = b.updated ?? "";
if (updatedA !== updatedB) return updatedA.localeCompare(updatedB);
return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare(
`${b.poNumber ?? ""}|${b.description ?? ""}`,
);
});
}
export type CollectionPoolGroup = {
supplierId: string;
supplierName: string;
/** Cluster colour token from the proximity map (may be undefined). */
markerToken?: string;
/** First two distinct PO numbers for the header eyebrow. */
topPoNumbers: string[];
/** Number of distinct POs beyond the top two. */
remainingPoCount: number;
/** Rows belonging to this group, in render order. */
rows: CollectionPoolViewRow[];
};
/**
* Folds already-sorted rows into per-supplier groups, preserving input
* order. The caller is responsible for sorting rows by `supplierName`
* (use `buildVisibleRows` first); this helper does not re-sort.
*/
export function buildSupplierGroups(
rows: CollectionPoolViewRow[],
supplierMarkerMap: Map<string, string>,
): CollectionPoolGroup[] {
const groups: CollectionPoolGroup[] = [];
const bySupplier = new Map<string, CollectionPoolGroup>();
for (const row of rows) {
let group = bySupplier.get(row.supplierId);
if (!group) {
group = {
supplierId: row.supplierId,
supplierName: row.supplierName ?? "",
markerToken: supplierMarkerMap.get(row.supplierId),
topPoNumbers: [],
remainingPoCount: 0,
rows: [],
};
bySupplier.set(row.supplierId, group);
groups.push(group);
}
group.rows.push(row);
}
for (const group of groups) {
const uniquePos = Array.from(
new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)),
);
group.topPoNumbers = uniquePos.slice(0, 2);
group.remainingPoCount = Math.max(0, uniquePos.length - 2);
}
return groups;
}
export function buildProximityMarkerMap(
suppliers: Array<{ supplierId: string; lat: number; lng: number }>,
radiusKm: number,
): Map<string, string> {
const clusters = buildClusters(suppliers, radiusKm);
const result = new Map<string, string>();
clusters.forEach((cluster, index) => {
const token = MARKER_TOKENS[index % MARKER_TOKENS.length];
cluster.items.forEach((item) => {
result.set(item.supplierId, token);
});
});
return result;
}

View file

@ -13,9 +13,11 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({
const createOrderFromQuote = vi.fn();
const getOrderItemsCollectionName = vi.fn(() => "order_items");
const listCollectionPool = vi.fn();
vi.mock("@/lib/server/orders-service", () => ({
createOrderFromQuote: (...args: unknown[]) => createOrderFromQuote(...args),
getOrderItemsCollectionName: () => getOrderItemsCollectionName(),
listCollectionPool: (...args: unknown[]) => listCollectionPool(...args),
}));
const createQuoteFromEmail = vi.fn();

View file

@ -138,6 +138,22 @@ export async function generateOrderNumberServer(
return formatOrderNumber(year, month, updated.last_seq);
}
export async function listCollectionPool(params: { status?: string; page?: number; perPage?: number }) {
const pb = await getAdminPocketBase();
const page = params.page || 1;
const perPage = params.perPage || 50;
const status = params.status || "";
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
/**
* Returns the (year, month) that the next order number should be keyed
* on. If there's a most recent order, that's the (year, month) — the