feat(collection-pool): add page and service function
Restores the /collection-pool feature in this codebase.
- src/lib/server/orders-service.ts
- Re-adds `listCollectionPool({ status, page, perPage })` which
queries the `collection_pool` PB collection via the admin
client (filter on pool_status, sort -created, expand
order_item/order/supplier/drv_id). MCP route.ts already
imported this — without it, the MCP route failed to
compile.
- src/app/(app)/collection-pool/{page,components,view-model}*.tsx
- Re-creates the page directory (5 files, 2 315 lines) carrying
the supplier-group + per-stop checkbox UI, the
`CollectionPoolRowSource = "customer" | "stock"` union, the
assignment-state badge helpers (buildProximityMarkerMap,
buildSupplierGroups, buildVisibleRows), and the
MarkReadyDialog / AssignDriverDialog wiring.
- src/app/api/mcp/route.test.ts
- Adds `const listCollectionPool = vi.fn()` and exposes it in
the vi.mock factory so the existing collection_pool.list test
cases (and the bulk_update_quote_items ones that call
`.mockReset()` on every fixture) stop crashing with
'ReferenceError: listCollectionPool is not defined'.
Not in this commit (kept narrow per request):
- The /api/v1/collection-pool route handler and its test
- The 'Collection Pool' entry in the sidebar nav
- The migration script that creates the live `collection_pool`
PB collection (it doesn't exist on the cloud PB instance
yet; the page render relies on it being created separately
or via `npm run pb:migrate:orders-v2`)
This commit is contained in:
parent
72fede1891
commit
811493bedb
7 changed files with 2333 additions and 0 deletions
468
src/app/(app)/collection-pool/components.tsx
Normal file
468
src/app/(app)/collection-pool/components.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, Truck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { OrderItem } from "@/lib/types";
|
||||
import {
|
||||
type CollectionPoolGroup,
|
||||
getAssignmentBadge,
|
||||
getAssignmentState,
|
||||
getPoolAgeLabel,
|
||||
} from "./view-model";
|
||||
|
||||
type CollectionPoolRow = OrderItem & {
|
||||
ready_for_collection?: boolean | null;
|
||||
/**
|
||||
* Discriminator for union rows. `source === "stock"` rows come
|
||||
* from `stock_purchase_items` and use `stock_line_status` for
|
||||
* the per-line state machine. The display + actions branch on
|
||||
* this field — see `PoolRow` for the Mark Collected button.
|
||||
*/
|
||||
source?: "customer" | "stock";
|
||||
/**
|
||||
* Per-line state for stock rows. One of
|
||||
* `collection_pool` | `assigned_to_driver` | `collected`. The
|
||||
* pool page only renders `collection_pool` and
|
||||
* `assigned_to_driver` (the `collected` rows move to
|
||||
* collection-confirm). The Mark Collected button shows when
|
||||
* this is `assigned_to_driver`.
|
||||
*/
|
||||
stock_line_status?: string | null;
|
||||
expand?: {
|
||||
purchase_order?: {
|
||||
id: string;
|
||||
po_number?: string;
|
||||
supplier?: string;
|
||||
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
|
||||
status?: string;
|
||||
};
|
||||
order?: {
|
||||
id: string;
|
||||
order_number?: string;
|
||||
company?: string;
|
||||
customer?: string;
|
||||
expand?: {
|
||||
company?: { id: string; company?: string };
|
||||
customer?: { id: string; customer?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CollectionPoolVisibleRow = CollectionPoolRow & {
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
companyName: string;
|
||||
customerName: string;
|
||||
poNumber: string;
|
||||
orderNumber: string;
|
||||
};
|
||||
|
||||
function markerTokenToClassName(token?: string): string {
|
||||
switch (token) {
|
||||
case "marker-blue":
|
||||
return "bg-blue-500";
|
||||
case "marker-emerald":
|
||||
return "bg-emerald-500";
|
||||
case "marker-violet":
|
||||
return "bg-violet-500";
|
||||
case "marker-amber":
|
||||
return "bg-amber-500";
|
||||
default:
|
||||
return "bg-slate-300";
|
||||
}
|
||||
}
|
||||
|
||||
export function PoolRow({
|
||||
row,
|
||||
busy,
|
||||
markerToken,
|
||||
todayYmd,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onOpenAssign,
|
||||
onToggleReady,
|
||||
onMarkStockCollected,
|
||||
}: {
|
||||
row: CollectionPoolVisibleRow;
|
||||
busy: boolean;
|
||||
markerToken?: string;
|
||||
todayYmd: string;
|
||||
selected: boolean;
|
||||
onToggleSelected: (rowId: string) => void;
|
||||
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
|
||||
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
|
||||
onMarkStockCollected: (row: CollectionPoolVisibleRow) => void;
|
||||
}) {
|
||||
const assignmentState = getAssignmentState(row, todayYmd);
|
||||
const assignmentBadge = getAssignmentBadge(assignmentState);
|
||||
const isAssigned = assignmentState !== "unassigned";
|
||||
const readyLabel = row.ready_for_collection ? "Ready" : "Pending";
|
||||
const assignedDateLabel = row.assigned_at
|
||||
? new Date(row.assigned_at).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})
|
||||
: null;
|
||||
// Stock rows hide the customer/order context (it's empty for
|
||||
// stock lines) and surface the PO + quantity prominently. The
|
||||
// "Mark Ready" / "Mark Pending" button is also hidden — stock
|
||||
// lines are simply "in the pool" until a driver is assigned.
|
||||
// The Mark Collected button shows once the line is in
|
||||
// `assigned_to_driver` so the operator can flip it to
|
||||
// `collected` once the driver returns.
|
||||
const isStock = row.source === "stock";
|
||||
const showMarkCollected = isStock && row.stock_line_status === "assigned_to_driver";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"grid grid-cols-[6px_minmax(0,1fr)]",
|
||||
selected && "bg-primary/5",
|
||||
)}
|
||||
aria-busy={busy}
|
||||
>
|
||||
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
|
||||
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] lg:items-center">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelected(row.id)}
|
||||
disabled={busy}
|
||||
aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`}
|
||||
className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground inline-flex items-center gap-2">
|
||||
<span>{row.description || "(no description)"}</span>
|
||||
{/* Stock-source badge. Same primary-color treatment as
|
||||
collection-confirm so the operator sees at a
|
||||
glance which rows are supplier pickups vs. customer
|
||||
deliveries. Hidden for customer rows. */}
|
||||
{isStock && (
|
||||
<span
|
||||
data-od-id="row-stock-badge"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
|
||||
>
|
||||
Stock
|
||||
</span>
|
||||
)}
|
||||
{isStock && row.order_qty != null && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
· Qty {row.order_qty}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{/* Customer/company context — only relevant for customer
|
||||
rows. Stock rows show "No customer / No company"
|
||||
otherwise, which is misleading. */}
|
||||
{!isStock && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{row.customerName || "No customer"}</span>
|
||||
<span className="text-border">/</span>
|
||||
<span>{row.companyName || "No company"}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isStock && !row.purchase_order && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200/80 bg-amber-50/70 px-2.5 py-2 text-[11px] leading-5 text-amber-900">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>This row has no purchase order link. The PO may have been deleted.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 text-sm">
|
||||
{!isStock && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Order</span>
|
||||
<Link href={`/orders/${row.order}`} className="min-w-0 truncate text-foreground hover:underline">
|
||||
{row.orderNumber || row.order}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 font-mono text-xs text-muted-foreground">{row.poNumber || "No PO"}</div>
|
||||
<div className="mt-1 text-base font-semibold text-primary">
|
||||
{getPoolAgeLabel(row.updated)}
|
||||
</div>
|
||||
{row.updated && (
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
In pool since {new Date(row.updated).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{assignedDateLabel && <div className="mt-1 text-xs text-muted-foreground">Scheduled {assignedDateLabel}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 lg:flex-col lg:items-start">
|
||||
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
|
||||
{assignmentBadge.label}
|
||||
</span>
|
||||
{/* Ready/pending badge is customer-only — stock lines
|
||||
don't have a `ready_for_collection` concept. */}
|
||||
{!isStock && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
row.ready_for_collection
|
||||
? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60"
|
||||
: "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
|
||||
)}
|
||||
>
|
||||
{readyLabel}
|
||||
</span>
|
||||
)}
|
||||
{!isStock && row.notes && row.notes.trim().length > 0 && (
|
||||
<span className="group/notes relative inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Notes
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute left-0 top-full z-20 mt-1 hidden w-72 rounded-md border bg-popover px-3 py-2 text-xs font-normal normal-case tracking-normal text-popover-foreground shadow-md group-hover/notes:block group-focus-within/notes:block"
|
||||
>
|
||||
{row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-2 lg:min-w-[11rem]">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAssigned ? "outline" : "default"}
|
||||
onClick={() => onOpenAssign(row)}
|
||||
disabled={busy || (!isStock && !row.ready_for_collection)}
|
||||
title={
|
||||
!isStock && !row.ready_for_collection
|
||||
? "Mark this stop as ready before assigning"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isAssigned ? "Reschedule" : "Assign Driver"}
|
||||
</Button>
|
||||
{/* Ready toggle only applies to customer rows. Stock
|
||||
rows skip straight to the assign action. */}
|
||||
{!isStock && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={row.ready_for_collection ? "outline" : "default"}
|
||||
onClick={() => onToggleReady(row, !row.ready_for_collection)}
|
||||
disabled={busy}
|
||||
className={
|
||||
row.ready_for_collection
|
||||
? undefined
|
||||
: "border-emerald-600 bg-emerald-600 text-white shadow-sm shadow-emerald-900/10 hover:border-emerald-700 hover:bg-emerald-700"
|
||||
}
|
||||
>
|
||||
{row.ready_for_collection ? "Mark Pending" : "Mark Ready"}
|
||||
</Button>
|
||||
)}
|
||||
{/* Stock-only: once the driver is assigned, the operator
|
||||
clicks "Mark Collected" when the driver returns with
|
||||
the goods. That flips the line from
|
||||
`assigned_to_driver` to `collected`, which moves it
|
||||
from this list into the collection-confirm queue for
|
||||
book-to-stock. */}
|
||||
{showMarkCollected && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onMarkStockCollected(row)}
|
||||
disabled={busy}
|
||||
data-od-id="row-mark-collected"
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 size-3.5" />
|
||||
Mark Collected
|
||||
</Button>
|
||||
)}
|
||||
{busy && (
|
||||
<span className="inline-flex items-center justify-end gap-1 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
||||
Updating...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupplierGroup({
|
||||
group,
|
||||
busy,
|
||||
todayYmd,
|
||||
selectedIds,
|
||||
collapsed,
|
||||
onToggleRowSelected,
|
||||
onToggleSectionSelected,
|
||||
onToggleCollapsed,
|
||||
onOpenAssign,
|
||||
onOpenBulkAssign,
|
||||
onToggleReady,
|
||||
onMarkStockCollected,
|
||||
}: {
|
||||
// `group.rows` is typed as `CollectionPoolViewRow[]` in the helper, but
|
||||
// `PoolRow` (this page's existing row component) expects the richer
|
||||
// `CollectionPoolVisibleRow` (which extends `OrderItem`). The widening is
|
||||
// sound because `buildSupplierGroups` is fed `CollectionPoolVisibleRow[]`
|
||||
// — see Task 7 wiring.
|
||||
group: Omit<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] };
|
||||
busy: boolean;
|
||||
todayYmd: string;
|
||||
selectedIds: Set<string>;
|
||||
collapsed: boolean;
|
||||
onToggleRowSelected: (rowId: string) => void;
|
||||
onToggleSectionSelected: (rowIds: string[]) => void;
|
||||
onToggleCollapsed: (supplierId: string) => void;
|
||||
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
|
||||
/**
|
||||
* Bulk-assign opener. Accepts the actual row objects (not just
|
||||
* ids) so the parent can capture the per-row `source` for the
|
||||
* write loop — customer rows go to `order_items`, stock rows go
|
||||
* to `stock_purchase_items`.
|
||||
*/
|
||||
onOpenBulkAssign: (supplierId: string, supplierName: string, rows: CollectionPoolVisibleRow[]) => void;
|
||||
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
|
||||
onMarkStockCollected: (row: CollectionPoolVisibleRow) => void;
|
||||
}) {
|
||||
const rowIds = group.rows.map((r) => r.id);
|
||||
const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length;
|
||||
const allSelected = selectedCount === rowIds.length && rowIds.length > 0;
|
||||
const someSelected = selectedCount > 0 && !allSelected;
|
||||
// For stock rows, `getAssignmentState` reads the union row's
|
||||
// `drv_id` / `assigned_at` — both populated by the assign-driver
|
||||
// PATCH. The state machine maps identically (drv_id present =
|
||||
// assigned), so the same badge works for both sources.
|
||||
const allAssigned = group.rows.every(
|
||||
(r) => getAssignmentState(r, todayYmd) !== "unassigned",
|
||||
);
|
||||
|
||||
// Compute which rows WOULD be assigned if the user clicked the bulk
|
||||
// assign button. For customer rows the gate is "ready"; for stock
|
||||
// rows the gate is just "unassigned" (no ready toggle exists).
|
||||
const targetRows = allSelected || selectedCount === 0
|
||||
? group.rows
|
||||
: group.rows.filter((r) => selectedIds.has(r.id));
|
||||
const unreadyTargetCount = targetRows.filter(
|
||||
(r) => r.source !== "stock" && !r.ready_for_collection,
|
||||
).length;
|
||||
const allTargetsReady = unreadyTargetCount === 0;
|
||||
|
||||
const eyebrowText = (() => {
|
||||
if (group.topPoNumbers.length === 0) return null;
|
||||
const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", ");
|
||||
return group.remainingPoCount > 0
|
||||
? `${head} (+${group.remainingPoCount} more)`
|
||||
: head;
|
||||
})();
|
||||
|
||||
const assignLabel = allSelected
|
||||
? `Assign ${rowIds.length} stops`
|
||||
: selectedCount > 0
|
||||
? `Assign ${selectedCount} selected`
|
||||
: `Assign all ${rowIds.length}`;
|
||||
|
||||
const assignDisabledReason = allAssigned
|
||||
? "Every stop at this supplier is already assigned"
|
||||
: !allTargetsReady
|
||||
? `${unreadyTargetCount} ${unreadyTargetCount === 1 ? "stop" : "stops"} not marked ready — mark ready before assigning`
|
||||
: undefined;
|
||||
|
||||
const onAssignClick = () => {
|
||||
const targetRowList = allSelected || selectedCount === 0
|
||||
? group.rows
|
||||
: group.rows.filter((r) => selectedIds.has(r.id));
|
||||
onOpenBulkAssign(group.supplierId, group.supplierName, targetRowList);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`supplier-${group.supplierId}`}
|
||||
data-od-id={`collection-pool-supplier-${group.supplierId}`}
|
||||
className="border-b last:border-b-0"
|
||||
>
|
||||
<header className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5">
|
||||
<span
|
||||
className={cn("size-2.5 shrink-0 rounded-full", markerTokenToClassName(group.markerToken))}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3
|
||||
id={`supplier-${group.supplierId}`}
|
||||
className="text-sm font-semibold text-foreground"
|
||||
>
|
||||
{group.supplierName || "Unknown supplier"}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"}
|
||||
{eyebrowText ? <> · {eyebrowText}</> : null}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={() => onToggleSectionSelected(rowIds)}
|
||||
disabled={busy}
|
||||
className="size-4 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
||||
aria-label={`Select all ${group.rows.length} stops at ${group.supplierName || "unknown supplier"}`}
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAssignClick}
|
||||
disabled={busy || allAssigned || !allTargetsReady}
|
||||
title={assignDisabledReason}
|
||||
>
|
||||
<Truck className="mr-1.5 size-3.5" />
|
||||
{assignLabel}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleCollapsed(group.supplierId)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`supplier-${group.supplierId}-body`}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={cn("size-4 transition-transform", collapsed && "-rotate-90")} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
id={`supplier-${group.supplierId}-body`}
|
||||
className={cn(
|
||||
"grid overflow-hidden transition-[grid-template-rows] duration-150 ease-out",
|
||||
collapsed ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
|
||||
)}
|
||||
>
|
||||
<ul className="divide-y min-h-0">
|
||||
{group.rows.map((row) => (
|
||||
<PoolRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
busy={busy}
|
||||
markerToken={group.markerToken}
|
||||
todayYmd={todayYmd}
|
||||
selected={selectedIds.has(row.id)}
|
||||
onToggleSelected={onToggleRowSelected}
|
||||
onOpenAssign={onOpenAssign}
|
||||
onToggleReady={onToggleReady}
|
||||
onMarkStockCollected={onMarkStockCollected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
364
src/app/(app)/collection-pool/page.test.tsx
Normal file
364
src/app/(app)/collection-pool/page.test.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import CollectionPoolPage from "./page";
|
||||
|
||||
const {
|
||||
getFullListMock,
|
||||
getMappableSuppliersMock,
|
||||
toastErrorMock,
|
||||
toastSuccessMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getFullListMock: vi.fn(),
|
||||
getMappableSuppliersMock: vi.fn(),
|
||||
toastErrorMock: vi.fn(),
|
||||
toastSuccessMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: toastErrorMock,
|
||||
success: toastSuccessMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
getPocketBase: () => ({
|
||||
collection: (name: string) => {
|
||||
// The page's `load()` now reads from BOTH `order_items`
|
||||
// (customer deliveries) and `stock_purchase_items` (supplier
|
||||
// pickups). Test data is all customer rows; the stock side
|
||||
// returns an empty list so the union query doesn't try to
|
||||
// render anything for stock.
|
||||
if (name === "order_items") {
|
||||
return { getFullList: getFullListMock, update: vi.fn() };
|
||||
}
|
||||
if (name === "stock_purchase_items") {
|
||||
return { getFullList: vi.fn(async () => []), update: vi.fn() };
|
||||
}
|
||||
throw new Error(`Unexpected collection: ${name}`);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/pb-helpers", () => ({
|
||||
getMappableSuppliers: getMappableSuppliersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/assign-driver-dialog", () => ({
|
||||
AssignDriverDialog: () => null,
|
||||
}));
|
||||
|
||||
function makeRow({
|
||||
id,
|
||||
supplierId,
|
||||
supplierName,
|
||||
orderNumber,
|
||||
itemStatus,
|
||||
driverId,
|
||||
assignedAt,
|
||||
}: {
|
||||
id: string;
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
orderNumber: string;
|
||||
itemStatus: string;
|
||||
driverId?: string;
|
||||
assignedAt?: string;
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
order: `order-${id}`,
|
||||
purchase_order: `po-${id}`,
|
||||
description: `${supplierName} item`,
|
||||
updated: "2026-07-05T08:00:00.000Z",
|
||||
item_status: itemStatus,
|
||||
drv_id: driverId ?? "",
|
||||
assigned_at: assignedAt ?? "",
|
||||
ready_for_collection: false,
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: `po-${id}`,
|
||||
po_number: `PO-${id}`,
|
||||
supplier: supplierId,
|
||||
expand: {
|
||||
supplier: {
|
||||
id: supplierId,
|
||||
suppliername: supplierName,
|
||||
},
|
||||
},
|
||||
},
|
||||
order: {
|
||||
id: `order-${id}`,
|
||||
order_number: orderNumber,
|
||||
expand: {
|
||||
company: { id: `company-${id}`, company: "Anglo" },
|
||||
customer: { id: `customer-${id}`, customer: "North Pit" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getRenderedSupplierOrder() {
|
||||
const list = screen.getByRole("list");
|
||||
return within(list)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.textContent ?? "");
|
||||
}
|
||||
|
||||
function makeRowWithOverrides(overrides: Record<string, unknown>) {
|
||||
return makeRow({
|
||||
id: "alpha",
|
||||
supplierId: "sup-alpha",
|
||||
supplierName: "Alpha Bearings",
|
||||
orderNumber: "SO-1",
|
||||
itemStatus: "collection_pool",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeRowWithNotes(notes: string) {
|
||||
const row = makeRowWithOverrides({});
|
||||
return { ...row, notes };
|
||||
}
|
||||
|
||||
describe("CollectionPoolPage supplier groups + notes", () => {
|
||||
beforeEach(() => {
|
||||
getFullListMock.mockResolvedValue([
|
||||
makeRow({
|
||||
id: "alpha",
|
||||
supplierId: "sup-alpha",
|
||||
supplierName: "Alpha Bearings",
|
||||
orderNumber: "SO-1",
|
||||
itemStatus: "collection_pool",
|
||||
}),
|
||||
makeRow({
|
||||
id: "bravo",
|
||||
supplierId: "sup-bravo",
|
||||
supplierName: "Bravo Industrial",
|
||||
orderNumber: "SO-2",
|
||||
itemStatus: "collection_pool",
|
||||
}),
|
||||
makeRow({
|
||||
id: "charlie",
|
||||
supplierId: "sup-charlie",
|
||||
supplierName: "Charlie Mining",
|
||||
orderNumber: "SO-3",
|
||||
itemStatus: "collection_pool",
|
||||
}),
|
||||
]);
|
||||
|
||||
getMappableSuppliersMock.mockResolvedValue([
|
||||
{ id: "sup-alpha", coordinates: "-26.2041, 28.0473" },
|
||||
{ id: "sup-bravo", coordinates: "-26.3000, 28.1000" },
|
||||
{ id: "sup-charlie", coordinates: "-26.2048, 28.0479" },
|
||||
]);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders time-in-list with text-primary class", async () => {
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
const ageLabels = await screen.findAllByText(/^\d+[hd]$/);
|
||||
expect(ageLabels.length).toBeGreaterThan(0);
|
||||
for (const label of ageLabels) {
|
||||
expect(label.className).toMatch(/text-primary/);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces the Notes tooltip on row hover", async () => {
|
||||
// Override first mock row to include notes text — only rows with notes render the chip.
|
||||
getFullListMock.mockResolvedValueOnce([
|
||||
makeRowWithNotes("Spoke to David — confirmed pickup tomorrow"),
|
||||
]);
|
||||
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent(/Spoke to David/);
|
||||
// Tooltip uses the `hidden` class by default; shown only on group-hover/focus-within.
|
||||
expect(tooltip.className).toMatch(/hidden/);
|
||||
});
|
||||
|
||||
it("applies a primary-tint background to selected rows", async () => {
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
const firstCheckbox = await screen.findByRole("checkbox", {
|
||||
name: /Select .* at .*/i,
|
||||
});
|
||||
fireEvent.click(firstCheckbox);
|
||||
|
||||
const row = firstCheckbox.closest("li");
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.className).toMatch(/bg-primary\/5/);
|
||||
});
|
||||
|
||||
it("section Select all toggles every row in the supplier group", async () => {
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
const selectAll = await screen.findByRole("checkbox", {
|
||||
name: /Select all \d+ stops at/i,
|
||||
});
|
||||
fireEvent.click(selectAll);
|
||||
|
||||
const rowCheckboxes = await screen.findAllByRole("checkbox", {
|
||||
name: /^Select .* at/i,
|
||||
});
|
||||
expect(rowCheckboxes.length).toBeGreaterThan(0);
|
||||
for (const cb of rowCheckboxes) {
|
||||
expect(cb).toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => {
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
const selectAll = await screen.findByRole("checkbox", {
|
||||
name: /Select all \d+ stops at/i,
|
||||
});
|
||||
fireEvent.click(selectAll);
|
||||
|
||||
// After toggling section select all, the assign button label becomes
|
||||
// `Assign N stops` (where N = group rows.length).
|
||||
const assignAll = await screen.findByRole("button", {
|
||||
name: /Assign \d+ stops$/,
|
||||
});
|
||||
fireEvent.click(assignAll);
|
||||
|
||||
// The AssignDriverDialog component is mocked to render `null` in this test
|
||||
// file (see top of file), so the actual dialog content isn't in the DOM.
|
||||
// We assert the click handler ran by checking that the section's assign
|
||||
// button still exists / is reachable, and that row checkboxes remain
|
||||
// checked (state preserved across the dialog mount).
|
||||
const rowCheckboxes = screen.getAllByRole("checkbox", {
|
||||
name: /^Select .* at/i,
|
||||
});
|
||||
expect(rowCheckboxes.length).toBeGreaterThan(0);
|
||||
for (const cb of rowCheckboxes) {
|
||||
expect(cb).toBeChecked();
|
||||
}
|
||||
|
||||
// Sanity: the button label reflects the bulk-assign state — when all rows
|
||||
// in a section are selected the label switches from "Assign all N" to
|
||||
// "Assign N stops".
|
||||
expect(assignAll.textContent ?? "").toMatch(/Assign \d+ stops/);
|
||||
});
|
||||
|
||||
it("collapses a supplier section on chevron click", async () => {
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
const section = document.querySelector('[aria-labelledby^="supplier-"]');
|
||||
expect(section).not.toBeNull();
|
||||
const headerId = section!.querySelector("header")!.id;
|
||||
const body = document.getElementById(`${headerId}-body`);
|
||||
expect(body).not.toBeNull();
|
||||
// Default (expanded) state: grid-rows-[1fr].
|
||||
expect(body!.className).toMatch(/grid-rows-\[1fr\]/);
|
||||
|
||||
const chevron = section!.querySelector(
|
||||
'button[aria-expanded]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(chevron).not.toBeNull();
|
||||
fireEvent.click(chevron!);
|
||||
|
||||
// After click: the section body collapses to grid-rows-[0fr].
|
||||
expect(body!.className).toMatch(/grid-rows-\[0fr\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CollectionPoolPage sort control", () => {
|
||||
beforeEach(() => {
|
||||
getFullListMock.mockResolvedValue([
|
||||
makeRow({
|
||||
id: "bravo",
|
||||
supplierId: "sup-bravo",
|
||||
supplierName: "Bravo Industrial",
|
||||
orderNumber: "SO-2",
|
||||
itemStatus: "collection_pool",
|
||||
}),
|
||||
makeRow({
|
||||
id: "alpha",
|
||||
supplierId: "sup-alpha",
|
||||
supplierName: "Alpha Bearings",
|
||||
orderNumber: "SO-1",
|
||||
itemStatus: "assigned",
|
||||
driverId: "drv-1",
|
||||
assignedAt: "2026-07-07T00:00:00.000Z",
|
||||
}),
|
||||
makeRow({
|
||||
id: "charlie",
|
||||
supplierId: "sup-charlie",
|
||||
supplierName: "Charlie Mining",
|
||||
orderNumber: "SO-3",
|
||||
itemStatus: "assigned",
|
||||
driverId: "drv-2",
|
||||
assignedAt: "2026-07-08T00:00:00.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
getMappableSuppliersMock.mockResolvedValue([
|
||||
{ id: "sup-alpha", coordinates: "-26.2041, 28.0473" },
|
||||
{ id: "sup-charlie", coordinates: "-26.2048, 28.0479" },
|
||||
{ id: "sup-bravo", coordinates: "-26.3000, 28.1000" },
|
||||
]);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults to operational sort and reorders rows when nearby suppliers is selected", async () => {
|
||||
render(<CollectionPoolPage />);
|
||||
|
||||
await screen.findByText("Alpha Bearings");
|
||||
|
||||
const operationalButton = screen.getByRole("radio", { name: "Operational" });
|
||||
const nearbyButton = screen.getByRole("radio", { name: "Nearby suppliers" });
|
||||
|
||||
expect(operationalButton.getAttribute("aria-checked")).toBe("true");
|
||||
expect(nearbyButton.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await waitFor(() => {
|
||||
const rows = getRenderedSupplierOrder();
|
||||
expect(rows[0]).toContain("Alpha Bearings");
|
||||
expect(rows[1]).toContain("Bravo Industrial");
|
||||
expect(rows[2]).toContain("Charlie Mining");
|
||||
});
|
||||
|
||||
fireEvent.click(nearbyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(operationalButton.getAttribute("aria-checked")).toBe("false");
|
||||
expect(nearbyButton.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
const rows = getRenderedSupplierOrder();
|
||||
expect(rows[0]).toContain("Alpha Bearings");
|
||||
expect(rows[1]).toContain("Charlie Mining");
|
||||
expect(rows[2]).toContain("Bravo Industrial");
|
||||
});
|
||||
});
|
||||
});
|
||||
890
src/app/(app)/collection-pool/page.tsx
Normal file
890
src/app/(app)/collection-pool/page.tsx
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Truck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AssignDriverDialog,
|
||||
type AssignDriverSubmitPayload,
|
||||
} from "@/components/ui/assign-driver-dialog";
|
||||
import { MarkReadyDialog } from "@/components/ui/mark-ready-dialog";
|
||||
import { parseCoordinates } from "@/lib/geo";
|
||||
import { getPocketBase } from "@/lib/pocketbase";
|
||||
import { getMappableSuppliers } from "@/lib/pb-helpers";
|
||||
import type { OrderItem } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
buildProximityMarkerMap,
|
||||
buildSupplierGroups,
|
||||
buildVisibleRows,
|
||||
type CollectionPoolGroup,
|
||||
type CollectionPoolSortMode,
|
||||
getAssignmentBadge,
|
||||
getAssignmentState,
|
||||
getPoolAgeLabel,
|
||||
} from "./view-model";
|
||||
|
||||
import { PoolRow, SupplierGroup, type CollectionPoolVisibleRow } from "./components";
|
||||
|
||||
/**
|
||||
* Pool row source. The collection-pool page now reads from BOTH
|
||||
* `order_items` (customer deliveries, `item_status = "collection_pool"`
|
||||
* or "assigned") and `stock_purchase_items` (supplier pickups,
|
||||
* `status = "collection_pool"` or "assigned_to_driver"). The two
|
||||
* have different field names + state machines so we normalise
|
||||
* to a `CollectionPoolRow` with a `source` discriminator.
|
||||
*/
|
||||
type CollectionPoolRowSource = "customer" | "stock";
|
||||
|
||||
type CollectionPoolRow = Partial<Omit<OrderItem, "item_status" | "assigned_at">> & {
|
||||
/** Row id (PB). Always set for both sources. */
|
||||
id: string;
|
||||
/**
|
||||
* Per-row status. Customer rows use the `ItemStatus` union
|
||||
* (`collection_pool` | `assigned` | etc.); stock rows use the
|
||||
* `StockPurchaseLineStatus` union (`collection_pool` |
|
||||
* `assigned_to_driver` | ...). The two string sets overlap on
|
||||
* the unassigned value but otherwise diverge — typed as a
|
||||
* permissive string here so the union row shape accepts both.
|
||||
* `getAssignmentState` reads this for badge logic; it checks
|
||||
* `=== "assigned"`, which stock rows will never hit, so the
|
||||
* assignment state for stock rows is driven by `drv_id` only.
|
||||
*/
|
||||
item_status?: string;
|
||||
/** Which collection this row came from. */
|
||||
source?: CollectionPoolRowSource;
|
||||
/**
|
||||
* Stock-only: PB id of the parent `stock_purchase`. Always set
|
||||
* for `source: "stock"` rows; null/undefined for customer rows.
|
||||
*/
|
||||
stock_purchase_id?: string | null;
|
||||
/**
|
||||
* Stock-only: the per-line `status` value at fetch time. Possible
|
||||
* values: `collection_pool` | `assigned_to_driver` | `collected` |
|
||||
* `booked`. We only fetch `collection_pool` and `assigned_to_driver`
|
||||
* here — `collected` moves to collection-confirm.
|
||||
*/
|
||||
stock_line_status?: string | null;
|
||||
/**
|
||||
* Customer-only: supplier-ready flag (set via the
|
||||
* "Mark Ready" button). Stock rows don't have this.
|
||||
*/
|
||||
ready_for_collection?: boolean | null;
|
||||
expand?: {
|
||||
purchase_order?: {
|
||||
id: string;
|
||||
po_number?: string;
|
||||
supplier?: string;
|
||||
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
|
||||
status?: string;
|
||||
};
|
||||
order?: {
|
||||
id: string;
|
||||
order_number?: string;
|
||||
company?: string;
|
||||
customer?: string;
|
||||
expand?: {
|
||||
company?: { id: string; company?: string };
|
||||
customer?: { id: string; customer?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type CollectionPoolAssignDialogState = {
|
||||
row: CollectionPoolVisibleRow;
|
||||
initialDriverId: string;
|
||||
initialDriverLabel?: string;
|
||||
initialDate?: string;
|
||||
intent: "assign" | "reschedule";
|
||||
};
|
||||
|
||||
function assignedAtToYmd(iso: string | undefined | null): string | undefined {
|
||||
if (!iso) return undefined;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-16 text-center">
|
||||
<Truck className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CollectionPoolPage() {
|
||||
const [rows, setRows] = useState<CollectionPoolRow[] | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [readyFilter, setReadyFilter] = useState<"all" | "ready" | "unready">("all");
|
||||
const [sortMode, setSortMode] = useState<CollectionPoolSortMode>("operational");
|
||||
const [supplierCoords, setSupplierCoords] = useState<Map<string, { lat: number; lng: number }> | null>(null);
|
||||
const supFetchPromise = useRef<Promise<void> | null>(null);
|
||||
const [driversById, setDriversById] = useState<Record<string, string>>({});
|
||||
const [assignDialog, setAssignDialog] = useState<CollectionPoolAssignDialogState | null>(null);
|
||||
const [readyDialogRow, setReadyDialogRow] = useState<CollectionPoolVisibleRow | null>(null);
|
||||
const [readyDialogError, setReadyDialogError] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [collapsedSuppliers, setCollapsedSuppliers] = useState<Set<string>>(new Set());
|
||||
const [bulkAssignDialog, setBulkAssignDialog] = useState<{
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
rowIds: string[];
|
||||
/** Map of rowId → source captured at dialog-open time. Drives
|
||||
* the write path in `handleBulkAssignSubmit` (customer →
|
||||
* `order_items`, stock → `stock_purchase_items`). */
|
||||
sourceByRowId: Record<string, CollectionPoolRowSource>;
|
||||
} | null>(null);
|
||||
|
||||
const toggleRowSelected = useCallback((rowId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowId)) next.delete(rowId);
|
||||
else next.add(rowId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSectionSelected = useCallback((rowIds: string[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
const allSelected = rowIds.every((id) => prev.has(id));
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
for (const id of rowIds) next.delete(id);
|
||||
} else {
|
||||
for (const id of rowIds) next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback((supplierId: string) => {
|
||||
setCollapsedSuppliers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(supplierId)) next.delete(supplierId);
|
||||
else next.add(supplierId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openBulkAssign = useCallback(
|
||||
(supplierId: string, supplierName: string, rows: CollectionPoolVisibleRow[]) => {
|
||||
if (rows.length === 0) return;
|
||||
const rowIds = rows.map((r) => r.id);
|
||||
// Capture the source per row so the assign loop in
|
||||
// `handleBulkAssignSubmit` knows which collection to write
|
||||
// to (customer → `order_items`, stock → `stock_purchase_items`).
|
||||
const sourceByRowId: Record<string, CollectionPoolRowSource> = {};
|
||||
for (const r of rows) {
|
||||
sourceByRowId[r.id] = (r.source ?? "customer") as CollectionPoolRowSource;
|
||||
}
|
||||
setBulkAssignDialog({ supplierId, supplierName, rowIds, sourceByRowId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []);
|
||||
|
||||
const closeAssignDialog = useCallback(() => setAssignDialog(null), []);
|
||||
|
||||
const openAssignForRow = useCallback((row: CollectionPoolVisibleRow) => {
|
||||
const driverLabel = row.drv_id ? driversById[row.drv_id] ?? `Driver ${row.drv_id.slice(0, 8)}` : undefined;
|
||||
setAssignDialog({
|
||||
row,
|
||||
initialDriverId: row.drv_id ?? "",
|
||||
initialDriverLabel: driverLabel,
|
||||
initialDate: assignedAtToYmd(row.assigned_at),
|
||||
intent: getAssignmentState(row, new Date().toISOString().slice(0, 10)) === "unassigned" ? "assign" : "reschedule",
|
||||
});
|
||||
}, [driversById]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRows(null);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
// Union query: customer deliveries from `order_items`
|
||||
// (`item_status = "collection_pool" || "assigned"`) UNION
|
||||
// supplier pickups from `stock_purchase_items`
|
||||
// (`status = "collection_pool" || "assigned_to_driver"`).
|
||||
//
|
||||
// The two state machines don't share field names so we
|
||||
// normalise stock rows into the same `CollectionPoolRow`
|
||||
// shape with a `source` discriminator. Downstream code
|
||||
// branches on `row.source` for write actions (assign,
|
||||
// collect) — customer writes go to `order_items`, stock
|
||||
// writes go to `stock_purchase_items`.
|
||||
const [customerRows, stockRows] = await Promise.all([
|
||||
pb.collection("order_items").getFullList<CollectionPoolRow>({
|
||||
filter: 'item_status = "collection_pool" || item_status = "assigned"',
|
||||
sort: "updated",
|
||||
expand: "purchase_order.supplier,order.company,order.customer",
|
||||
}),
|
||||
pb
|
||||
.collection("stock_purchase_items")
|
||||
.getFullList<
|
||||
CollectionPoolRow & {
|
||||
id: string;
|
||||
stock_item: string;
|
||||
quantity: number;
|
||||
status?: string | null;
|
||||
assigned_at?: string | null;
|
||||
expand?: {
|
||||
purchase?: {
|
||||
id: string;
|
||||
purchase_number: string;
|
||||
expand?: {
|
||||
supplier?: { id: string; suppliername?: string; email?: string };
|
||||
};
|
||||
};
|
||||
stock_item?: { id: string; sku?: string; name?: string };
|
||||
};
|
||||
}
|
||||
>({
|
||||
filter: 'status = "collection_pool" || status = "assigned_to_driver"',
|
||||
sort: "-created",
|
||||
expand: "purchase.supplier,purchase,stock_item",
|
||||
})
|
||||
.catch(() => [] as unknown[]),
|
||||
]);
|
||||
|
||||
// Normalise stock rows into the CollectionPoolRow shape. We
|
||||
// populate the same fields the customer branch uses
|
||||
// (po_number from `expand.purchase_order.po_number` etc.) so
|
||||
// the existing display + assign flow can treat both sources
|
||||
// uniformly.
|
||||
const stockAsPool: CollectionPoolRow[] = (
|
||||
stockRows as unknown as Array<{
|
||||
id: string;
|
||||
stock_item: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
status?: string | null;
|
||||
assigned_at?: string | null;
|
||||
drv_id?: string;
|
||||
expand?: CollectionPoolRow["expand"] & {
|
||||
purchase?: {
|
||||
id: string;
|
||||
purchase_number: string;
|
||||
expand?: { supplier?: { id: string; suppliername?: string; email?: string } };
|
||||
};
|
||||
stock_item?: { id: string; sku?: string; name?: string };
|
||||
};
|
||||
}>
|
||||
).map((s) => ({
|
||||
// Use `s.id` as both the row id and the `id` field so the
|
||||
// existing display treats it like a normal row.
|
||||
id: s.id,
|
||||
// Marker for downstream code that branches on source.
|
||||
source: "stock" as const,
|
||||
stock_purchase_id: s.expand?.purchase?.id ?? null,
|
||||
stock_line_status: s.status ?? null,
|
||||
// `description` is what the supplier link / order cells read.
|
||||
description: s.description,
|
||||
// `order_qty` mirrors `quantity` (no customer_qty split for
|
||||
// stock — the line is just "we ordered N units").
|
||||
order_qty: s.quantity,
|
||||
quantity: s.quantity,
|
||||
// The customer-style status field stays null for stock rows;
|
||||
// the assign / collect buttons read `stock_line_status` to
|
||||
// know which table to write to.
|
||||
item_status: s.status ?? undefined,
|
||||
// Carry through the assignment so the "Assigned to ..." badge
|
||||
// renders correctly when the driver has been assigned.
|
||||
assigned_at: s.assigned_at ?? null,
|
||||
// Stock rows don't have a customer-order relation. We
|
||||
// leave `expand.order` undefined so the display can omit
|
||||
// the customer/order cells.
|
||||
expand: {
|
||||
// Reuse the `purchase_order` field name so the existing
|
||||
// display's PO link works for both sources.
|
||||
purchase_order: s.expand?.purchase
|
||||
? {
|
||||
id: s.expand.purchase.id,
|
||||
po_number: s.expand.purchase.purchase_number,
|
||||
// `supplier` is the supplier relation id, used by
|
||||
// the existing assign-by-supplier-group logic.
|
||||
supplier: s.expand.purchase.expand?.supplier?.id,
|
||||
expand: { supplier: s.expand.purchase.expand?.supplier },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const customerAsPool: CollectionPoolRow[] = customerRows.map((r) => ({
|
||||
...r,
|
||||
source: "customer" as const,
|
||||
}));
|
||||
|
||||
setRows([...customerAsPool, ...stockAsPool]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
setRows([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (supFetchPromise.current) return;
|
||||
|
||||
supFetchPromise.current = (async () => {
|
||||
try {
|
||||
const suppliers = await getMappableSuppliers();
|
||||
const next = new Map<string, { lat: number; lng: number }>();
|
||||
for (const supplier of suppliers) {
|
||||
const coords = parseCoordinates(supplier.coordinates);
|
||||
if (!coords || !supplier.id) continue;
|
||||
next.set(supplier.id, { lat: coords[0], lng: coords[1] });
|
||||
}
|
||||
setSupplierCoords(next);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Couldn't load supplier locations: ${message}`);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let cancelled = false;
|
||||
fetch("/api/v1/users/lookup")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((data: { id: string; name?: string; email?: string }[]) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const user of data) {
|
||||
if (!user.id) continue;
|
||||
next[user.id] = user.name || user.email || `Driver ${user.id.slice(0, 8)}`;
|
||||
}
|
||||
setDriversById(next);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort only. Reschedule still falls back to the driver id slice.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const todayYmd = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const supplierMarkerMap = useMemo(() => {
|
||||
if (!supplierCoords) return new Map<string, string>();
|
||||
|
||||
return buildProximityMarkerMap(
|
||||
Array.from(supplierCoords.entries()).map(([supplierId, coord]) => ({
|
||||
supplierId,
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
})),
|
||||
2,
|
||||
);
|
||||
}, [supplierCoords]);
|
||||
|
||||
const clusterSortKeyForSupplier = useCallback(
|
||||
(supplierId: string) => supplierMarkerMap.get(supplierId) ?? `solo-${supplierId}`,
|
||||
[supplierMarkerMap],
|
||||
);
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
if (!rows) return null;
|
||||
|
||||
return buildVisibleRows(
|
||||
rows.map((row) => {
|
||||
// For stock rows, the supplier id sits on
|
||||
// `expand.purchase_order.supplier` (we set that on the way
|
||||
// through `load()`). For customer rows, same path. Falls
|
||||
// back to a sentinel "unknown" so the row still groups.
|
||||
const supplierId = row.expand?.purchase_order?.supplier ?? "unknown";
|
||||
return {
|
||||
...row,
|
||||
supplierId,
|
||||
supplierName: row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier",
|
||||
// Stock rows have no customer/order relation — the
|
||||
// company/customer cells stay blank. They show PO +
|
||||
// description + qty, which is enough for the operator.
|
||||
companyName: row.expand?.order?.expand?.company?.company ?? "",
|
||||
customerName: row.expand?.order?.expand?.customer?.customer ?? "",
|
||||
poNumber: row.expand?.purchase_order?.po_number ?? "",
|
||||
orderNumber: row.expand?.order?.order_number ?? "",
|
||||
clusterSortKey: clusterSortKeyForSupplier(supplierId),
|
||||
};
|
||||
}),
|
||||
{
|
||||
todayYmd,
|
||||
searchText: filterText,
|
||||
readyFilter,
|
||||
sortMode,
|
||||
},
|
||||
) as CollectionPoolVisibleRow[];
|
||||
}, [rows, todayYmd, filterText, readyFilter, sortMode, clusterSortKeyForSupplier]);
|
||||
|
||||
const groups = useMemo<
|
||||
| (Omit<CollectionPoolGroup, "rows"> & { rows: CollectionPoolVisibleRow[] })[]
|
||||
| null
|
||||
>(() => {
|
||||
if (!visibleRows) return null;
|
||||
// `buildSupplierGroups` types its rows as `CollectionPoolViewRow[]`,
|
||||
// but every row in `visibleRows` is a `CollectionPoolVisibleRow` (a
|
||||
// superset of `CollectionPoolViewRow` — see the cast above). The
|
||||
// widening per-row is sound.
|
||||
return buildSupplierGroups(visibleRows, supplierMarkerMap).map((g) => ({
|
||||
...g,
|
||||
rows: g.rows as CollectionPoolVisibleRow[],
|
||||
}));
|
||||
}, [visibleRows, supplierMarkerMap]);
|
||||
|
||||
const updateItemStatus = useCallback(
|
||||
async (
|
||||
itemId: string,
|
||||
patch: Partial<Pick<OrderItem, "item_status" | "drv_id" | "drv_status" | "assigned_at">>,
|
||||
) => {
|
||||
setBusyId(itemId);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
await pb.collection("order_items").update(itemId, patch);
|
||||
toast.success(`Item ${itemId.slice(0, 8)} updated.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
// Stock-line write: PATCH to `stock_purchase_items` instead of
|
||||
// `order_items`. Used by the assign-driver and mark-collected
|
||||
// handlers when `row.source === "stock"`. The state machine on
|
||||
// the stock side is `collection_pool` → `assigned_to_driver` →
|
||||
// `collected` (→ "booked" via the collection-confirm page, not
|
||||
// here). Note that `assigned_at` is a `date` field on the stock
|
||||
// side (no time component), so we send just the yyyy-mm-dd.
|
||||
const updateStockLineStatus = useCallback(
|
||||
async (
|
||||
itemId: string,
|
||||
patch: { status?: string; drv_id?: string; assigned_at?: string | null },
|
||||
) => {
|
||||
setBusyId(itemId);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
await pb.collection("stock_purchase_items").update(itemId, patch);
|
||||
toast.success(`Stock line ${itemId.slice(0, 8)} updated.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const handleAssignDialogSubmit = useCallback(
|
||||
async (payload: AssignDriverSubmitPayload) => {
|
||||
const { driverId, date } = payload;
|
||||
if (!assignDialog) return;
|
||||
|
||||
setBusyId(assignDialog.row.id);
|
||||
try {
|
||||
if (assignDialog.row.source === "stock") {
|
||||
// Stock-line assign: writes to `stock_purchase_items`.
|
||||
// `assigned_at` is a date field (no time component).
|
||||
await updateStockLineStatus(assignDialog.row.id, {
|
||||
status: "assigned_to_driver",
|
||||
drv_id: driverId,
|
||||
assigned_at: date,
|
||||
});
|
||||
} else {
|
||||
await updateItemStatus(assignDialog.row.id, {
|
||||
item_status: "assigned",
|
||||
drv_id: driverId,
|
||||
drv_status: "assigned",
|
||||
assigned_at: `${date}T00:00:00.000Z`,
|
||||
} as Partial<OrderItem>);
|
||||
}
|
||||
closeAssignDialog();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
},
|
||||
[assignDialog, closeAssignDialog, updateItemStatus, updateStockLineStatus],
|
||||
);
|
||||
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
|
||||
const handleBulkAssignSubmit = useCallback(
|
||||
async (payload: AssignDriverSubmitPayload) => {
|
||||
if (!bulkAssignDialog) return;
|
||||
const { driverId, date } = payload;
|
||||
const { rowIds, supplierName, sourceByRowId } = bulkAssignDialog;
|
||||
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
let successCount = 0;
|
||||
let firstError: string | null = null;
|
||||
|
||||
// Sequential, not parallel: any failure leaves earlier
|
||||
// successes committed (matches the single-row PATCH semantics).
|
||||
// Source map is captured at dialog-open time so the loop
|
||||
// doesn't need to re-derive per iteration.
|
||||
for (const id of rowIds) {
|
||||
const source = sourceByRowId[id] ?? "customer";
|
||||
try {
|
||||
if (source === "stock") {
|
||||
await pb.collection("stock_purchase_items").update(id, {
|
||||
status: "assigned_to_driver",
|
||||
drv_id: driverId,
|
||||
assigned_at: date,
|
||||
});
|
||||
} else {
|
||||
await pb.collection("order_items").update(id, {
|
||||
item_status: "assigned",
|
||||
drv_id: driverId,
|
||||
drv_status: "assigned",
|
||||
assigned_at: `${date}T00:00:00.000Z`,
|
||||
} as Partial<OrderItem>);
|
||||
}
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
if (!firstError) {
|
||||
firstError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selections for this supplier on success (partial or full).
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of rowIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
closeBulkAssign();
|
||||
|
||||
if (firstError) {
|
||||
toast.error(
|
||||
`Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`);
|
||||
}
|
||||
|
||||
await load();
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
},
|
||||
[bulkAssignDialog, closeBulkAssign, load],
|
||||
);
|
||||
|
||||
// Mark-collected: stock-only flow. Flips a stock line from
|
||||
// `assigned_to_driver` to `collected`, which moves the row from
|
||||
// the collection-pool to the collection-confirm queue. The
|
||||
// operator clicks this once the driver returns with the goods
|
||||
// from the supplier. The button only renders for stock rows
|
||||
// (see PoolRow); for customer lines the collected transition
|
||||
// happens elsewhere (driver app / direct PB write).
|
||||
const handleMarkStockCollected = useCallback(
|
||||
async (row: CollectionPoolVisibleRow) => {
|
||||
if (row.source !== "stock") return;
|
||||
await updateStockLineStatus(row.id, { status: "collected" });
|
||||
},
|
||||
[updateStockLineStatus],
|
||||
);
|
||||
|
||||
const handleToggleReady = useCallback(
|
||||
async (row: CollectionPoolVisibleRow, ready: boolean) => {
|
||||
// Unmarking is a single PATCH with no note. The modal only opens
|
||||
// for the "mark ready" direction.
|
||||
if (!ready) {
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ready_for_collection: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
toast.success("Marked pending supplier confirmation.");
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Marking ready opens the modal. The actual PATCH is fired from
|
||||
// `submitMarkReady` once the operator has typed a note.
|
||||
setReadyDialogError(null);
|
||||
setReadyDialogRow(row);
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const submitMarkReady = useCallback(
|
||||
async (note: string) => {
|
||||
const row = readyDialogRow;
|
||||
if (!row) return;
|
||||
setBusyId(row.id);
|
||||
setReadyDialogError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ready_for_collection: true,
|
||||
ready_notes: note,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const message = body?.error || `HTTP ${res.status}`;
|
||||
// Keep the modal open so the operator can fix the error.
|
||||
setReadyDialogError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
toast.success("Marked ready — note saved.");
|
||||
setReadyDialogRow(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
// Error already shown via readyDialogError / toast.
|
||||
console.error("Mark ready failed:", err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
},
|
||||
[load, readyDialogRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Truck className="h-5 w-5 text-muted-foreground" />
|
||||
Collection Pool
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Order items awaiting driver pickup in one operational list. Assign, reschedule, and update readiness from here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="flex flex-col gap-3 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-lg border bg-background px-3 py-2 lg:min-w-[22rem] lg:max-w-xl lg:flex-1">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Search supplier, item, PO, or order..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
aria-label="Search collection pool"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterText("")}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium uppercase tracking-wide">Sort</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Sort collection pool"
|
||||
className="inline-flex rounded-md border bg-card shadow-sm"
|
||||
>
|
||||
{([
|
||||
{ id: "operational", label: "Operational" },
|
||||
{ id: "nearby", label: "Nearby suppliers" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={sortMode === opt.id}
|
||||
onClick={() => setSortMode(opt.id)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs transition-colors",
|
||||
sortMode === opt.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium uppercase tracking-wide">Ready</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Filter by ready-for-collection status"
|
||||
className="inline-flex rounded-lg border bg-background p-1"
|
||||
>
|
||||
{(["all", "ready", "unready"] as const).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={readyFilter === opt}
|
||||
onClick={() => setReadyFilter(opt)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
readyFilter === opt
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt === "all" ? "All" : opt === "ready" ? "Ready to collect" : "Pending"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<div className="hidden grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] gap-4 border-b bg-muted/20 px-4 py-2 pl-[22px] text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground lg:grid">
|
||||
<span>Collection Item</span>
|
||||
<span>Order Detail</span>
|
||||
<span>Status</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
{groups === null ? (
|
||||
<div className="space-y-2 p-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-20 w-full animate-pulse rounded-md bg-muted/30"
|
||||
aria-label="Loading..."
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState message="No items match the current filters." />
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<SupplierGroup
|
||||
key={group.supplierId}
|
||||
group={group}
|
||||
busy={busyId !== null}
|
||||
todayYmd={todayYmd}
|
||||
selectedIds={selectedIds}
|
||||
collapsed={collapsedSuppliers.has(group.supplierId)}
|
||||
onToggleRowSelected={toggleRowSelected}
|
||||
onToggleSectionSelected={toggleSectionSelected}
|
||||
onToggleCollapsed={toggleCollapsed}
|
||||
onOpenAssign={openAssignForRow}
|
||||
onOpenBulkAssign={openBulkAssign}
|
||||
onToggleReady={handleToggleReady}
|
||||
onMarkStockCollected={handleMarkStockCollected}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssignDriverDialog
|
||||
open={assignDialog !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeAssignDialog();
|
||||
}}
|
||||
onSubmit={handleAssignDialogSubmit}
|
||||
intent={assignDialog?.intent}
|
||||
initialDriverId={assignDialog?.initialDriverId ?? ""}
|
||||
initialDriverLabel={assignDialog?.initialDriverLabel}
|
||||
initialDate={assignDialog?.initialDate}
|
||||
busy={assignDialog ? busyId === assignDialog.row.id : false}
|
||||
bulkCount={1}
|
||||
eyebrow={
|
||||
assignDialog ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Truck className="size-3.5 text-muted-foreground" />
|
||||
{assignDialog.row.supplierName}
|
||||
{assignDialog.row.poNumber ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
PO {assignDialog.row.poNumber}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<AssignDriverDialog
|
||||
open={bulkAssignDialog !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeBulkAssign();
|
||||
}}
|
||||
onSubmit={handleBulkAssignSubmit}
|
||||
bulkCount={bulkAssignDialog?.rowIds.length ?? 1}
|
||||
eyebrow={
|
||||
bulkAssignDialog ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Truck className="size-3.5 text-muted-foreground" />
|
||||
{bulkAssignDialog.supplierName || "Unknown supplier"}
|
||||
<span className="text-muted-foreground">·</span>
|
||||
{bulkAssignDialog.rowIds.length} stops
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
busy={bulkBusy}
|
||||
/>
|
||||
{readyDialogRow && (
|
||||
<MarkReadyDialog
|
||||
open
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setReadyDialogRow(null);
|
||||
setReadyDialogError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={submitMarkReady}
|
||||
poLabel={readyDialogRow.poNumber}
|
||||
itemLabel={readyDialogRow.description}
|
||||
busy={busyId === readyDialogRow.id}
|
||||
serverError={readyDialogError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
src/app/(app)/collection-pool/view-model.test.ts
Normal file
381
src/app/(app)/collection-pool/view-model.test.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildProximityMarkerMap,
|
||||
buildSupplierGroups,
|
||||
buildVisibleRows,
|
||||
type CollectionPoolGroup,
|
||||
type CollectionPoolSortMode,
|
||||
getAssignmentBadge,
|
||||
getAssignmentState,
|
||||
getPoolAgeLabel,
|
||||
type CollectionPoolViewRow,
|
||||
} from "./view-model";
|
||||
|
||||
function makeRow(overrides: Partial<CollectionPoolViewRow> = {}): CollectionPoolViewRow {
|
||||
return {
|
||||
id: "row-1",
|
||||
description: "Wheel hub kit",
|
||||
updated: "2026-07-05T08:00:00.000Z",
|
||||
item_status: "collection_pool",
|
||||
drv_id: "",
|
||||
assigned_at: "",
|
||||
ready_for_collection: false,
|
||||
supplierId: "sup-1",
|
||||
supplierName: "Alpha Bearings",
|
||||
companyName: "Anglo",
|
||||
customerName: "North Pit",
|
||||
poNumber: "PO-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAssignmentState", () => {
|
||||
it("returns unassigned when driver fields are empty", () => {
|
||||
expect(getAssignmentState(makeRow(), "2026-07-07")).toBe("unassigned");
|
||||
});
|
||||
|
||||
it("returns assigned_today when assigned_at lands on today", () => {
|
||||
expect(
|
||||
getAssignmentState(
|
||||
makeRow({
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-1",
|
||||
assigned_at: "2026-07-07T00:00:00.000Z",
|
||||
}),
|
||||
"2026-07-07",
|
||||
),
|
||||
).toBe("assigned_today");
|
||||
});
|
||||
|
||||
it("returns assigned_future when assigned_at is after today", () => {
|
||||
expect(
|
||||
getAssignmentState(
|
||||
makeRow({
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-1",
|
||||
assigned_at: "2026-07-09T00:00:00.000Z",
|
||||
}),
|
||||
"2026-07-07",
|
||||
),
|
||||
).toBe("assigned_future");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPoolAgeLabel", () => {
|
||||
it("formats fresh rows in hours and older rows in days", () => {
|
||||
expect(getPoolAgeLabel("2026-07-07T06:00:00.000Z", new Date("2026-07-07T12:00:00.000Z"))).toBe("6h");
|
||||
expect(getPoolAgeLabel("2026-07-03T12:00:00.000Z", new Date("2026-07-07T12:00:00.000Z"))).toBe("4d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssignmentBadge", () => {
|
||||
it("maps each assignment state to the expected label and token classes", () => {
|
||||
expect(getAssignmentBadge("unassigned")).toEqual({
|
||||
label: "Unassigned",
|
||||
className: "bg-slate-100 text-slate-700 ring-1 ring-slate-200/60",
|
||||
});
|
||||
expect(getAssignmentBadge("assigned_today")).toEqual({
|
||||
label: "Assigned today",
|
||||
className: "bg-blue-100 text-blue-800 ring-1 ring-blue-200/60",
|
||||
});
|
||||
expect(getAssignmentBadge("assigned_future")).toEqual({
|
||||
label: "Assigned future",
|
||||
className: "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVisibleRows", () => {
|
||||
it("sorts assigned today first, then unassigned, then future", () => {
|
||||
const rows = buildVisibleRows(
|
||||
[
|
||||
makeRow({
|
||||
id: "future",
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-2",
|
||||
assigned_at: "2026-07-08T00:00:00.000Z",
|
||||
}),
|
||||
makeRow({ id: "unassigned" }),
|
||||
makeRow({
|
||||
id: "today",
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-1",
|
||||
assigned_at: "2026-07-07T00:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
{ todayYmd: "2026-07-07", searchText: "", readyFilter: "all" },
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["today", "unassigned", "future"]);
|
||||
});
|
||||
|
||||
it("keeps assigned rows visible when ready-only filters are active", () => {
|
||||
const rows = buildVisibleRows(
|
||||
[
|
||||
makeRow({
|
||||
id: "assigned-ready",
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-1",
|
||||
assigned_at: "2026-07-07T00:00:00.000Z",
|
||||
ready_for_collection: true,
|
||||
}),
|
||||
makeRow({ id: "awaiting", ready_for_collection: false }),
|
||||
],
|
||||
{ todayYmd: "2026-07-07", searchText: "", readyFilter: "ready" },
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["assigned-ready"]);
|
||||
});
|
||||
|
||||
it("matches order numbers in the text search", () => {
|
||||
const rows = buildVisibleRows(
|
||||
[
|
||||
Object.assign(makeRow({ id: "order-match" }), { orderNumber: "SO-123" }) as CollectionPoolViewRow,
|
||||
Object.assign(makeRow({ id: "other" }), { orderNumber: "SO-999" }) as CollectionPoolViewRow,
|
||||
],
|
||||
{ todayYmd: "2026-07-07", searchText: "so-123", readyFilter: "all" },
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["order-match"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVisibleRows sort modes", () => {
|
||||
function sortRows(sortMode: CollectionPoolSortMode) {
|
||||
return buildVisibleRows(
|
||||
[
|
||||
makeRow({ id: "cluster-b-unassigned", clusterSortKey: "cluster-b" }),
|
||||
makeRow({
|
||||
id: "cluster-a-today",
|
||||
clusterSortKey: "cluster-a",
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-1",
|
||||
assigned_at: "2026-07-07T00:00:00.000Z",
|
||||
}),
|
||||
makeRow({
|
||||
id: "cluster-a-future",
|
||||
clusterSortKey: "cluster-a",
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-2",
|
||||
assigned_at: "2026-07-08T00:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
{ todayYmd: "2026-07-07", searchText: "", readyFilter: "all", sortMode },
|
||||
);
|
||||
}
|
||||
|
||||
it("keeps operational sort as the default mode", () => {
|
||||
expect(sortRows("operational").map((row) => row.id)).toEqual([
|
||||
"cluster-a-today",
|
||||
"cluster-b-unassigned",
|
||||
"cluster-a-future",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts by cluster key before operational order in nearby mode", () => {
|
||||
expect(sortRows("nearby").map((row) => row.id)).toEqual([
|
||||
"cluster-a-today",
|
||||
"cluster-a-future",
|
||||
"cluster-b-unassigned",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProximityMarkerMap", () => {
|
||||
it("assigns the same marker token to nearby suppliers", () => {
|
||||
const markers = buildProximityMarkerMap(
|
||||
[
|
||||
{ supplierId: "sup-a", lat: -26.2, lng: 28.0 },
|
||||
{ supplierId: "sup-b", lat: -26.2008, lng: 28.0008 },
|
||||
{ supplierId: "sup-c", lat: -26.4, lng: 28.4 },
|
||||
],
|
||||
2,
|
||||
);
|
||||
|
||||
expect(markers.get("sup-a")).toBe(markers.get("sup-b"));
|
||||
expect(markers.get("sup-a")).not.toBe(markers.get("sup-c"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVisibleRows sort modes (nearby pinning)", () => {
|
||||
const todayYmd = "2026-07-09";
|
||||
|
||||
it("operational sort is deterministic on the same input", () => {
|
||||
const rows: CollectionPoolViewRow[] = [
|
||||
makeRow({ id: "a", poNumber: "PO-A", updated: "2026-07-02T00:00:00.000Z" }),
|
||||
makeRow({ id: "b", poNumber: "PO-B", updated: "2026-07-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
const first = buildVisibleRows(rows, {
|
||||
todayYmd,
|
||||
searchText: "",
|
||||
readyFilter: "all",
|
||||
sortMode: "operational",
|
||||
});
|
||||
const second = buildVisibleRows(rows, {
|
||||
todayYmd,
|
||||
searchText: "",
|
||||
readyFilter: "all",
|
||||
sortMode: "operational",
|
||||
});
|
||||
|
||||
// Same input + same options = same output, every time. The actual
|
||||
// direction of the sort is determined by assignment state and
|
||||
// updated timestamp; the value of this test is that the function
|
||||
// is stable, not that it sorts in a particular direction.
|
||||
expect(first.map((r) => r.id)).toEqual(second.map((r) => r.id));
|
||||
expect(first).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("nearby sort groups rows by clusterSortKey ascending", () => {
|
||||
const rows: CollectionPoolViewRow[] = [
|
||||
makeRow({ id: "c", clusterSortKey: "cluster-c" }),
|
||||
makeRow({ id: "a", clusterSortKey: "cluster-a" }),
|
||||
makeRow({ id: "b", clusterSortKey: "cluster-b" }),
|
||||
];
|
||||
|
||||
const out = buildVisibleRows(rows, {
|
||||
todayYmd,
|
||||
searchText: "",
|
||||
readyFilter: "all",
|
||||
sortMode: "nearby",
|
||||
});
|
||||
|
||||
expect(out.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("nearby sort sinks rows with missing clusterSortKey to the end", () => {
|
||||
const rows: CollectionPoolViewRow[] = [
|
||||
makeRow({ id: "no-coords-1", clusterSortKey: undefined }),
|
||||
makeRow({ id: "a", clusterSortKey: "cluster-a" }),
|
||||
makeRow({ id: "no-coords-2", clusterSortKey: undefined }),
|
||||
makeRow({ id: "b", clusterSortKey: "cluster-b" }),
|
||||
];
|
||||
|
||||
const out = buildVisibleRows(rows, {
|
||||
todayYmd,
|
||||
searchText: "",
|
||||
readyFilter: "all",
|
||||
sortMode: "nearby",
|
||||
});
|
||||
|
||||
// Rows WITH coords come first, ordered by cluster key.
|
||||
// Rows WITHOUT coords come last (sink to the end).
|
||||
expect(out.map((r) => r.id)).toEqual(["a", "b", "no-coords-1", "no-coords-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVisibleRows — supplier-first sort (operational mode)", () => {
|
||||
const todayYmd = "2026-07-10";
|
||||
|
||||
it("sorts by supplierName first when sortMode is operational", () => {
|
||||
const rows = buildVisibleRows(
|
||||
[
|
||||
makeRow({ id: "beta", supplierName: "Beta" }),
|
||||
makeRow({ id: "acme", supplierName: "Acme" }),
|
||||
makeRow({ id: "alpha", supplierName: "Alpha" }),
|
||||
],
|
||||
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" },
|
||||
);
|
||||
expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("falls through to assignmentState within the same supplier", () => {
|
||||
const rows = buildVisibleRows(
|
||||
[
|
||||
makeRow({
|
||||
id: "acme-unassigned",
|
||||
supplierName: "Acme",
|
||||
item_status: "collection_pool",
|
||||
}),
|
||||
makeRow({
|
||||
id: "acme-today",
|
||||
supplierName: "Acme",
|
||||
item_status: "assigned",
|
||||
drv_id: "drv-1",
|
||||
assigned_at: `${todayYmd}T00:00:00.000Z`,
|
||||
}),
|
||||
],
|
||||
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" },
|
||||
);
|
||||
expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]);
|
||||
});
|
||||
|
||||
it("nearby mode still uses cluster prefix before supplier", () => {
|
||||
const rows = buildVisibleRows(
|
||||
[
|
||||
makeRow({
|
||||
id: "beta-cluster-a",
|
||||
supplierName: "Beta",
|
||||
clusterSortKey: "cluster-a",
|
||||
}),
|
||||
makeRow({
|
||||
id: "acme-cluster-b",
|
||||
supplierName: "Acme",
|
||||
clusterSortKey: "cluster-b",
|
||||
}),
|
||||
makeRow({
|
||||
id: "acme-cluster-a",
|
||||
supplierName: "Acme",
|
||||
clusterSortKey: "cluster-a",
|
||||
}),
|
||||
],
|
||||
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" },
|
||||
);
|
||||
expect(rows.map((r) => r.id)).toEqual([
|
||||
"acme-cluster-a",
|
||||
"beta-cluster-a",
|
||||
"acme-cluster-b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSupplierGroups", () => {
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(buildSupplierGroups([], new Map())).toEqual([]);
|
||||
});
|
||||
|
||||
it("folds rows by supplierId in input order", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }),
|
||||
makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }),
|
||||
makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }),
|
||||
];
|
||||
const groups = buildSupplierGroups(rows, new Map());
|
||||
expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]);
|
||||
expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]);
|
||||
expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("captures the top two PO numbers and reports the remaining count", () => {
|
||||
const rows = [
|
||||
makeRow({ supplierId: "sup-1", poNumber: "PO-1" }),
|
||||
makeRow({ supplierId: "sup-1", poNumber: "PO-2" }),
|
||||
makeRow({ supplierId: "sup-1", poNumber: "PO-3" }),
|
||||
makeRow({ supplierId: "sup-1", poNumber: "PO-4" }),
|
||||
makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO
|
||||
];
|
||||
const [group] = buildSupplierGroups(rows, new Map());
|
||||
expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]);
|
||||
expect(group.remainingPoCount).toBe(2);
|
||||
});
|
||||
|
||||
it("uses marker token from supplierMarkerMap", () => {
|
||||
const map = new Map([["sup-1", "marker-blue"]]);
|
||||
const [group] = buildSupplierGroups(
|
||||
[makeRow({ supplierId: "sup-1" })],
|
||||
map,
|
||||
);
|
||||
expect(group.markerToken).toBe("marker-blue");
|
||||
});
|
||||
|
||||
it("returns empty eyebrow when no rows have a PO number", () => {
|
||||
const [group] = buildSupplierGroups(
|
||||
[makeRow({ supplierId: "sup-1", poNumber: "" })],
|
||||
new Map(),
|
||||
);
|
||||
expect(group.topPoNumbers).toEqual([]);
|
||||
expect(group.remainingPoCount).toBe(0);
|
||||
});
|
||||
});
|
||||
212
src/app/(app)/collection-pool/view-model.ts
Normal file
212
src/app/(app)/collection-pool/view-model.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { buildClusters } from "@/lib/geo";
|
||||
|
||||
export type AssignmentState = "unassigned" | "assigned_today" | "assigned_future";
|
||||
export type AssignmentBadge = {
|
||||
label: string;
|
||||
className: string;
|
||||
};
|
||||
export type CollectionPoolSortMode = "operational" | "nearby";
|
||||
|
||||
export type CollectionPoolViewRow = {
|
||||
id: string;
|
||||
/**
|
||||
* Which collection this row came from. The pool page now
|
||||
* reads from both `order_items` and `stock_purchase_items`;
|
||||
* `source: "stock"` rows get a Stock badge and a different
|
||||
* write target on assign/collect.
|
||||
*/
|
||||
source?: "customer" | "stock";
|
||||
description?: string;
|
||||
updated?: string;
|
||||
item_status?: string;
|
||||
drv_id?: string;
|
||||
assigned_at?: string;
|
||||
ready_for_collection?: boolean | null;
|
||||
/** Stock-only: PB id of the parent `stock_purchase`. */
|
||||
stock_purchase_id?: string | null;
|
||||
/** Stock-only: per-line status at fetch time. */
|
||||
stock_line_status?: string | null;
|
||||
supplierId: string;
|
||||
supplierName?: string;
|
||||
companyName?: string;
|
||||
customerName?: string;
|
||||
poNumber?: string;
|
||||
orderNumber?: string;
|
||||
clusterSortKey?: string;
|
||||
};
|
||||
|
||||
const ASSIGNMENT_SORT_ORDER: Record<AssignmentState, number> = {
|
||||
assigned_today: 0,
|
||||
unassigned: 1,
|
||||
assigned_future: 2,
|
||||
};
|
||||
|
||||
const MARKER_TOKENS = ["marker-blue", "marker-emerald", "marker-violet", "marker-amber"] as const;
|
||||
|
||||
export function getAssignmentState(
|
||||
row: Pick<CollectionPoolViewRow, "item_status" | "drv_id" | "assigned_at">,
|
||||
todayYmd: string,
|
||||
): AssignmentState {
|
||||
const hasAssignment = Boolean(row.drv_id) || row.item_status === "assigned";
|
||||
if (!hasAssignment) return "unassigned";
|
||||
|
||||
const assignedYmd = row.assigned_at ? row.assigned_at.slice(0, 10) : "";
|
||||
return assignedYmd > todayYmd ? "assigned_future" : "assigned_today";
|
||||
}
|
||||
|
||||
export function getPoolAgeLabel(updatedAt: string | undefined, now = new Date()): string {
|
||||
if (!updatedAt) return "-";
|
||||
|
||||
const updated = new Date(updatedAt);
|
||||
if (Number.isNaN(updated.getTime())) return "-";
|
||||
|
||||
const diffMs = Math.max(0, now.getTime() - updated.getTime());
|
||||
const hours = Math.floor(diffMs / 3_600_000);
|
||||
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
export function getAssignmentBadge(state: AssignmentState): AssignmentBadge {
|
||||
switch (state) {
|
||||
case "assigned_today":
|
||||
return {
|
||||
label: "Assigned today",
|
||||
className: "bg-blue-100 text-blue-800 ring-1 ring-blue-200/60",
|
||||
};
|
||||
case "assigned_future":
|
||||
return {
|
||||
label: "Assigned future",
|
||||
className: "bg-violet-100 text-violet-800 ring-1 ring-violet-200/60",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: "Unassigned",
|
||||
className: "bg-slate-100 text-slate-700 ring-1 ring-slate-200/60",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildVisibleRows(
|
||||
rows: CollectionPoolViewRow[],
|
||||
options: {
|
||||
todayYmd: string;
|
||||
searchText: string;
|
||||
readyFilter: "all" | "ready" | "unready";
|
||||
sortMode?: CollectionPoolSortMode;
|
||||
},
|
||||
): CollectionPoolViewRow[] {
|
||||
const query = options.searchText.trim().toLowerCase();
|
||||
const sortMode = options.sortMode ?? "operational";
|
||||
|
||||
return rows
|
||||
.filter((row) => {
|
||||
if (options.readyFilter === "ready" && !row.ready_for_collection) return false;
|
||||
if (options.readyFilter === "unready" && row.ready_for_collection) return false;
|
||||
if (!query) return true;
|
||||
|
||||
return [
|
||||
row.supplierName ?? "",
|
||||
row.description ?? "",
|
||||
row.poNumber ?? "",
|
||||
row.orderNumber ?? "",
|
||||
row.customerName ?? "",
|
||||
row.companyName ?? "",
|
||||
].some((value) => value.toLowerCase().includes(query));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortMode === "nearby") {
|
||||
const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz");
|
||||
if (clusterDiff !== 0) return clusterDiff;
|
||||
}
|
||||
|
||||
// Supplier name is the primary group key. Rows in the same
|
||||
// supplier are then ordered by assignment urgency (today →
|
||||
// unassigned → future), then by updated, then by PO + description.
|
||||
const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? "");
|
||||
if (supplierDiff !== 0) return supplierDiff;
|
||||
|
||||
const stateDiff =
|
||||
ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] -
|
||||
ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)];
|
||||
if (stateDiff !== 0) return stateDiff;
|
||||
|
||||
const updatedA = a.updated ?? "";
|
||||
const updatedB = b.updated ?? "";
|
||||
if (updatedA !== updatedB) return updatedA.localeCompare(updatedB);
|
||||
|
||||
return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare(
|
||||
`${b.poNumber ?? ""}|${b.description ?? ""}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export type CollectionPoolGroup = {
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
/** Cluster colour token from the proximity map (may be undefined). */
|
||||
markerToken?: string;
|
||||
/** First two distinct PO numbers for the header eyebrow. */
|
||||
topPoNumbers: string[];
|
||||
/** Number of distinct POs beyond the top two. */
|
||||
remainingPoCount: number;
|
||||
/** Rows belonging to this group, in render order. */
|
||||
rows: CollectionPoolViewRow[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Folds already-sorted rows into per-supplier groups, preserving input
|
||||
* order. The caller is responsible for sorting rows by `supplierName`
|
||||
* (use `buildVisibleRows` first); this helper does not re-sort.
|
||||
*/
|
||||
export function buildSupplierGroups(
|
||||
rows: CollectionPoolViewRow[],
|
||||
supplierMarkerMap: Map<string, string>,
|
||||
): CollectionPoolGroup[] {
|
||||
const groups: CollectionPoolGroup[] = [];
|
||||
const bySupplier = new Map<string, CollectionPoolGroup>();
|
||||
|
||||
for (const row of rows) {
|
||||
let group = bySupplier.get(row.supplierId);
|
||||
if (!group) {
|
||||
group = {
|
||||
supplierId: row.supplierId,
|
||||
supplierName: row.supplierName ?? "",
|
||||
markerToken: supplierMarkerMap.get(row.supplierId),
|
||||
topPoNumbers: [],
|
||||
remainingPoCount: 0,
|
||||
rows: [],
|
||||
};
|
||||
bySupplier.set(row.supplierId, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.rows.push(row);
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const uniquePos = Array.from(
|
||||
new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)),
|
||||
);
|
||||
group.topPoNumbers = uniquePos.slice(0, 2);
|
||||
group.remainingPoCount = Math.max(0, uniquePos.length - 2);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function buildProximityMarkerMap(
|
||||
suppliers: Array<{ supplierId: string; lat: number; lng: number }>,
|
||||
radiusKm: number,
|
||||
): Map<string, string> {
|
||||
const clusters = buildClusters(suppliers, radiusKm);
|
||||
const result = new Map<string, string>();
|
||||
|
||||
clusters.forEach((cluster, index) => {
|
||||
const token = MARKER_TOKENS[index % MARKER_TOKENS.length];
|
||||
cluster.items.forEach((item) => {
|
||||
result.set(item.supplierId, token);
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue