diff --git a/src/app/(app)/collection-pool/components.tsx b/src/app/(app)/collection-pool/components.tsx
new file mode 100644
index 0000000..4790a97
--- /dev/null
+++ b/src/app/(app)/collection-pool/components.tsx
@@ -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 (
+
+
+
+
+
+
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"
+ />
+
+
+ {row.description || "(no description)"}
+ {/* 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 && (
+
+ Stock
+
+ )}
+ {isStock && row.order_qty != null && (
+
+ · Qty {row.order_qty}
+
+ )}
+
+ {/* Customer/company context — only relevant for customer
+ rows. Stock rows show "No customer / No company"
+ otherwise, which is misleading. */}
+ {!isStock && (
+
+ {row.customerName || "No customer"}
+ /
+ {row.companyName || "No company"}
+
+ )}
+ {!isStock && !row.purchase_order && (
+
+
+
This row has no purchase order link. The PO may have been deleted.
+
+ )}
+
+
+
+
+ {!isStock && (
+
+ Order
+
+ {row.orderNumber || row.order}
+
+
+ )}
+
{row.poNumber || "No PO"}
+
+ {getPoolAgeLabel(row.updated)}
+
+ {row.updated && (
+
+ In pool since {new Date(row.updated).toLocaleDateString(undefined, {
+ day: "numeric",
+ month: "short",
+ })}
+
+ )}
+ {assignedDateLabel &&
Scheduled {assignedDateLabel}
}
+
+
+
+
+ {assignmentBadge.label}
+
+ {/* Ready/pending badge is customer-only — stock lines
+ don't have a `ready_for_collection` concept. */}
+ {!isStock && (
+
+ {readyLabel}
+
+ )}
+ {!isStock && row.notes && row.notes.trim().length > 0 && (
+
+ Notes
+
+ {row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
+
+
+ )}
+
+
+
+ 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"}
+
+ {/* Ready toggle only applies to customer rows. Stock
+ rows skip straight to the assign action. */}
+ {!isStock && (
+ 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"}
+
+ )}
+ {/* 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 && (
+ onMarkStockCollected(row)}
+ disabled={busy}
+ data-od-id="row-mark-collected"
+ >
+
+ Mark Collected
+
+ )}
+ {busy && (
+
+
+ Updating...
+
+ )}
+
+
+
+ );
+}
+
+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 & { rows: CollectionPoolVisibleRow[] };
+ busy: boolean;
+ todayYmd: string;
+ selectedIds: Set;
+ 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 (
+
+
+
+
+ {group.rows.map((row) => (
+
+ ))}
+
+
+
+ );
+}
+
diff --git a/src/app/(app)/collection-pool/page.test.tsx b/src/app/(app)/collection-pool/page.test.tsx
new file mode 100644
index 0000000..de93cdb
--- /dev/null
+++ b/src/app/(app)/collection-pool/page.test.tsx
@@ -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 }) => (
+
+ {children}
+
+ ),
+}));
+
+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) {
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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");
+ });
+ });
+});
diff --git a/src/app/(app)/collection-pool/page.tsx b/src/app/(app)/collection-pool/page.tsx
new file mode 100644
index 0000000..ee7114a
--- /dev/null
+++ b/src/app/(app)/collection-pool/page.tsx
@@ -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> & {
+ /** 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 (
+
+ );
+}
+
+export default function CollectionPoolPage() {
+ const [rows, setRows] = useState(null);
+ const [busyId, setBusyId] = useState(null);
+ const [filterText, setFilterText] = useState("");
+ const [readyFilter, setReadyFilter] = useState<"all" | "ready" | "unready">("all");
+ const [sortMode, setSortMode] = useState("operational");
+ const [supplierCoords, setSupplierCoords] = useState | null>(null);
+ const supFetchPromise = useRef | null>(null);
+ const [driversById, setDriversById] = useState>({});
+ const [assignDialog, setAssignDialog] = useState(null);
+ const [readyDialogRow, setReadyDialogRow] = useState(null);
+ const [readyDialogError, setReadyDialogError] = useState(null);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [collapsedSuppliers, setCollapsedSuppliers] = useState>(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;
+ } | 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 = {};
+ 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({
+ 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();
+ 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 = {};
+ 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();
+
+ 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 & { 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>,
+ ) => {
+ 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);
+ }
+ 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);
+ }
+ 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 (
+
+
+
+
+
+
+
+ setFilterText(e.target.value)}
+ aria-label="Search collection pool"
+ />
+ {filterText && (
+ setFilterText("")}
+ className="text-xs font-medium text-muted-foreground hover:text-foreground"
+ >
+ Clear
+
+ )}
+
+
+
+
+
Sort
+
+ {([
+ { id: "operational", label: "Operational" },
+ { id: "nearby", label: "Nearby suppliers" },
+ ] as const).map((opt) => (
+ 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}
+
+ ))}
+
+
+
+
+
Ready
+
+ {(["all", "ready", "unready"] as const).map((opt) => (
+ 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"}
+
+ ))}
+
+
+
+
+
+
+
+
+ Collection Item
+ Order Detail
+ Status
+ Actions
+
+ {groups === null ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : groups.length === 0 ? (
+
+ ) : (
+ groups.map((group) => (
+
+ ))
+ )}
+
+
+
{
+ 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 ? (
+
+
+ {assignDialog.row.supplierName}
+ {assignDialog.row.poNumber ? (
+ <>
+ ·
+ PO {assignDialog.row.poNumber}
+ >
+ ) : null}
+
+ ) : undefined
+ }
+ />
+ {
+ if (!open) closeBulkAssign();
+ }}
+ onSubmit={handleBulkAssignSubmit}
+ bulkCount={bulkAssignDialog?.rowIds.length ?? 1}
+ eyebrow={
+ bulkAssignDialog ? (
+
+
+ {bulkAssignDialog.supplierName || "Unknown supplier"}
+ ·
+ {bulkAssignDialog.rowIds.length} stops
+
+ ) : undefined
+ }
+ busy={bulkBusy}
+ />
+ {readyDialogRow && (
+ {
+ if (!o) {
+ setReadyDialogRow(null);
+ setReadyDialogError(null);
+ }
+ }}
+ onSubmit={submitMarkReady}
+ poLabel={readyDialogRow.poNumber}
+ itemLabel={readyDialogRow.description}
+ busy={busyId === readyDialogRow.id}
+ serverError={readyDialogError}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(app)/collection-pool/view-model.test.ts b/src/app/(app)/collection-pool/view-model.test.ts
new file mode 100644
index 0000000..f2d03bc
--- /dev/null
+++ b/src/app/(app)/collection-pool/view-model.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/src/app/(app)/collection-pool/view-model.ts b/src/app/(app)/collection-pool/view-model.ts
new file mode 100644
index 0000000..759376d
--- /dev/null
+++ b/src/app/(app)/collection-pool/view-model.ts
@@ -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 = {
+ 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,
+ 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,
+): CollectionPoolGroup[] {
+ const groups: CollectionPoolGroup[] = [];
+ const bySupplier = new Map();
+
+ 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 {
+ const clusters = buildClusters(suppliers, radiusKm);
+ const result = new Map();
+
+ clusters.forEach((cluster, index) => {
+ const token = MARKER_TOKENS[index % MARKER_TOKENS.length];
+ cluster.items.forEach((item) => {
+ result.set(item.supplierId, token);
+ });
+ });
+
+ return result;
+}
diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts
index 3e2fd1c..8287694 100644
--- a/src/app/api/mcp/route.test.ts
+++ b/src/app/api/mcp/route.test.ts
@@ -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();
diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts
index 439d5e5..c232358 100644
--- a/src/lib/server/orders-service.ts
+++ b/src/lib/server/orders-service.ts
@@ -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