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} + + + )} +
    + +
    + + {/* Ready toggle only applies to customer rows. Stock + rows skip straight to the assign action. */} + {!isStock && ( + + )} + {/* 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 && ( + + )} + {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.supplierName || "Unknown supplier"} +

    + + · {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"} + {eyebrowText ? <> · {eyebrowText} : null} + +
    + + + +
    +
    +
    +
      + {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 ( +
    + +

    {message}

    +
    + ); +} + +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 ( +
    +
    +
    +

    + + Collection Pool +

    +

    + Order items awaiting driver pickup in one operational list. Assign, reschedule, and update readiness from here. +

    +
    +
    + +
    +
    + +
    +
    +
    + + setFilterText(e.target.value)} + aria-label="Search collection pool" + /> + {filterText && ( + + )} +
    + +
    +
    + Sort +
    + {([ + { id: "operational", label: "Operational" }, + { id: "nearby", label: "Nearby suppliers" }, + ] as const).map((opt) => ( + + ))} +
    +
    + +
    + Ready +
    + {(["all", "ready", "unready"] as const).map((opt) => ( + + ))} +
    +
    +
    +
    +
    + +
    +
    + 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