diff --git a/.superpowers/brainstorm/39145-1783449414/content/hybrid-expandable-layout.html b/.superpowers/brainstorm/39145-1783449414/content/hybrid-expandable-layout.html new file mode 100644 index 0000000..6bc6f5c --- /dev/null +++ b/.superpowers/brainstorm/39145-1783449414/content/hybrid-expandable-layout.html @@ -0,0 +1,105 @@ +

Option 3: Hybrid Table With Expandable Detail

+

Compact top-level rows with the full operational detail revealed only when a row is expanded.

+ +
+
+
Collection Pool · Hybrid compact list
+
+
+
+
Supplier / Items
+
Customer / Company
+
Pool Age
+
PO Number
+
Status
+
Actions
+
+ +
+
+
+
Bearings International
+
2 x Wheel hub kits
+
+
+
Mogalakwena North Pit
+
Anglo American Platinum
+
+
4d
+
PO-18294
+
+ Assigned today +
+
+ + +
+
+ +
+
Expanded detail panel
+
+
+
Items scheduled
+
Wheel hub kits, 2 units
+
+
+
Assignment
+
Assigned for today
+
Driver name hidden from main row
+
+
+
Supplier cluster
+
Purple side marker
+
Within 2 km of related suppliers
+
+
+
Extra actions
+
Mark Awaiting
+
Secondary action stays inside the panel
+
+
+
+ +
+
+
+
Hydraulic Supplies Co.
+
1 x Seal kit assembly
+
+
+
Rustenburg Workshop
+
Wolfram Fleet Services
+
+
6h
+
PO-18301
+
+ Unassigned +
+
+ + +
+
+
+
+
+ +
+
+

What it improves

+ +
+
+

Why I did not recommend it

+ +
+
diff --git a/.superpowers/brainstorm/39145-1783449414/content/waiting-terminal-1.html b/.superpowers/brainstorm/39145-1783449414/content/waiting-terminal-1.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/39145-1783449414/content/waiting-terminal-1.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/39145-1783449414/state/server-info b/.superpowers/brainstorm/39145-1783449414/state/server-info new file mode 100644 index 0000000..28d6a50 --- /dev/null +++ b/.superpowers/brainstorm/39145-1783449414/state/server-info @@ -0,0 +1 @@ +{"type":"server-started","port":57854,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:57854","screen_dir":"/Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/.superpowers/brainstorm/39145-1783449414/content","state_dir":"/Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/.superpowers/brainstorm/39145-1783449414/state"} diff --git a/.superpowers/brainstorm/39145-1783449414/state/server.pid b/.superpowers/brainstorm/39145-1783449414/state/server.pid new file mode 100644 index 0000000..8092875 --- /dev/null +++ b/.superpowers/brainstorm/39145-1783449414/state/server.pid @@ -0,0 +1 @@ +39145 diff --git a/docs/superpowers/plans/2026-07-07-collection-pool-redesign.md b/docs/superpowers/plans/2026-07-07-collection-pool-redesign.md new file mode 100644 index 0000000..3df2dd9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-collection-pool-redesign.md @@ -0,0 +1,631 @@ +# Collection Pool Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current grouped collection-pool page with one unified operational list that keeps required row details visible, preserves ready-state actions, and differentiates assigned rows with compact status indicators instead of separate sections. + +**Architecture:** Keep the existing data load and assignment PATCH endpoints, but extract the page's derived view logic into a small helper module so sorting, assignment-state classification, and proximity marker grouping can be tested independently. Then simplify `page.tsx` to render a single filtered `visibleRows` list and a consistent `PoolRow` layout for all states. + +**Tech Stack:** Next.js app router, React 19 client components, TypeScript, Tailwind CSS, Vitest, Playwright + +--- + +## File Structure + +- Modify: `src/app/(app)/collection-pool/page.tsx` + - Flatten the page-level view model and remove assigned/proximity section rendering. +- Create: `src/app/(app)/collection-pool/view-model.ts` + - Hold pure helper functions for assignment status, pool age text, proximity marker grouping, and unified row sorting. +- Create: `src/app/(app)/collection-pool/view-model.test.ts` + - Cover the pure helper behavior with focused Vitest cases. +- Optional Modify: `e2e/management.spec.ts` + - Do not change this file unless its shared helpers are needed. +- Create: `e2e/collection-pool.spec.ts` + - Add one smoke-like browser spec only if stable seeded data is available locally; otherwise rely on manual browser validation in Task 5 and skip this file. +- Modify: `docs/superpowers/plans/2026-07-07-collection-pool-redesign.md` + - Check off steps during execution. + +### Task 1: Extract And Test The Unified View Model + +**Files:** +- Create: `src/app/(app)/collection-pool/view-model.ts` +- Create: `src/app/(app)/collection-pool/view-model.test.ts` +- Modify: `src/app/(app)/collection-pool/page.tsx` + +- [ ] **Step 1: Write the failing view-model tests** + +```ts +import { describe, expect, it } from "vitest"; + +import { + buildVisibleRows, + buildProximityMarkerMap, + 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("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"]); + }); +}); + +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")); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: FAIL with `Cannot find module './view-model'` or missing export errors. + +- [ ] **Step 3: Write the minimal helper module** + +```ts +export type AssignmentState = "unassigned" | "assigned_today" | "assigned_future"; + +export type CollectionPoolViewRow = { + id: string; + description?: string; + updated?: string; + item_status?: string; + drv_id?: string; + assigned_at?: string; + ready_for_collection?: boolean | null; + supplierId: string; + supplierName?: string; + companyName?: string; + customerName?: string; + poNumber?: string; +}; + +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); + 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 buildVisibleRows(rows: CollectionPoolViewRow[], options: { todayYmd: string; searchText: string; readyFilter: "all" | "ready" | "unready" }) { + const q = options.searchText.trim().toLowerCase(); + 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 (!q) return true; + return [ + row.supplierName ?? "", + row.description ?? "", + row.poNumber ?? "", + row.customerName ?? "", + row.companyName ?? "", + ].some((value) => value.toLowerCase().includes(q)); + }) + .sort((a, b) => { + const order = { assigned_today: 0, unassigned: 1, assigned_future: 2 } as const; + const stateDiff = order[getAssignmentState(a, options.todayYmd)] - 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.supplierName ?? ""}|${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.supplierName ?? ""}|${b.poNumber ?? ""}|${b.description ?? ""}`, + ); + }); +} +``` + +- [ ] **Step 4: Add the proximity marker helper** + +```ts +import { buildClusters } from "@/lib/geo"; + +const MARKER_TOKENS = ["marker-blue", "marker-emerald", "marker-violet", "marker-amber"] as const; + +export function buildProximityMarkerMap( + suppliers: Array<{ supplierId: string; lat: number; lng: number }>, + radiusKm: number, +) { + 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; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: PASS with 4 passing tests. + +- [ ] **Step 6: Commit** + +```bash +git add "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" +git commit -m "test: cover collection pool view model" +``` + +### Task 2: Flatten Page State And Remove Grouped Rendering + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` +- Modify: `src/app/(app)/collection-pool/view-model.ts` +- Test: `src/app/(app)/collection-pool/view-model.test.ts` + +- [ ] **Step 1: Extend the tests for unified filtering inputs** + +```ts +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"]); +}); +``` + +- [ ] **Step 2: Run the tests to verify the new case fails if needed** + +Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: PASS or targeted failure that confirms the helper still needs adjustment. + +- [ ] **Step 3: Replace the page's split derived state with one visible list** + +```ts +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 visibleRows = useMemo(() => { + if (!rows) return null; + return buildVisibleRows( + rows.map((row) => ({ + ...row, + supplierId: row.expand?.purchase_order?.supplier ?? "unknown", + supplierName: row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier", + companyName: row.expand?.order?.expand?.company?.company ?? "", + customerName: row.expand?.order?.expand?.customer?.customer ?? "", + poNumber: row.expand?.purchase_order?.po_number ?? "", + })), + { + todayYmd: new Date().toISOString().slice(0, 10), + searchText: filterText, + readyFilter, + }, + ); +}, [rows, supplierCoords, filterText, readyFilter]); +``` + +- [ ] **Step 4: Delete the old grouped UI branches** + +```ts +// Remove these page-level branches and all JSX that depends on them: +// - assignedRows / unassignedRows +// - assignedByDriver +// - grouped +// - renderGroups +// - clusterOn / clusterRadiusKm / showIsolated +// - bulk supplier and cluster toolbars +``` + +- [ ] **Step 5: Replace the body render with a single list** + +```tsx +{rows !== null && visibleRows && visibleRows.length > 0 && ( +
    + {visibleRows.map((row) => ( + + ))} +
+)} +``` + +- [ ] **Step 6: Run the focused test suite** + +Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" +git commit -m "refactor: flatten collection pool list model" +``` + +### Task 3: Rebuild The Filter Bar And Row Layout + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` +- Modify: `src/app/(app)/collection-pool/view-model.ts` +- Test: `src/app/(app)/collection-pool/view-model.test.ts` + +- [ ] **Step 1: Keep only search plus ready filters in the header** + +```tsx +
+ Ready +
+ {(["all", "ready", "unready"] as const).map((opt) => ( + + ))} +
+
+``` + +- [ ] **Step 2: Introduce an assignment badge helper** + +```ts +export function getAssignmentBadge(state: AssignmentState) { + 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" }; + } +} +``` + +- [ ] **Step 3: Rebuild `PoolRow` as one operational row layout** + +```tsx +function PoolRow({ + row, + busy, + markerToken, + onOpenAssign, + onToggleReady, +}: { + row: CollectionPoolRow; + busy: boolean; + markerToken?: string; + onOpenAssign: (row: CollectionPoolRow) => void; + onToggleReady: (id: string, ready: boolean) => void; +}) { + const assignmentState = getAssignmentState(row, new Date().toISOString().slice(0, 10)); + const assignmentBadge = getAssignmentBadge(assignmentState); + const isAssigned = assignmentState !== "unassigned"; + + return ( +
  • + +
    +

    {row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"}

    +

    {row.description || "(no description)"}

    +
    + {row.expand?.order?.expand?.customer?.customer ?? "No customer"} + · + {row.expand?.order?.expand?.company?.company ?? "No company"} +
    +
    +
    +
    {row.expand?.purchase_order?.po_number ?? "No PO"}
    +
    {getPoolAgeLabel(row.updated)}
    +
    +
    + + {assignmentBadge.label} + + + {row.ready_for_collection ? "Ready" : "Awaiting"} + +
    +
    + + +
    +
  • + ); +} +``` + +- [ ] **Step 4: Remove row-level driver-name output and return-to-pool chrome** + +```tsx +// Delete these row fragments: +// - drv_id slices inside the assigned badge +// - per-driver section headers +// - Return to pool button in the main row action stack +// - dimmed assigned-row styling +``` + +- [ ] **Step 5: Run lint and the focused test suite** + +Run: `npm run lint -- "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: PASS with no ESLint errors. + +Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" +git commit -m "feat: redesign collection pool rows" +``` + +### Task 4: Preserve Existing Assignment And Ready Actions In The New Layout + +**Files:** +- Modify: `src/app/(app)/collection-pool/page.tsx` +- Modify: `src/components/ui/assign-driver-dialog.tsx` +- Test: `src/app/(app)/collection-pool/view-model.test.ts` + +- [ ] **Step 1: Confirm the dialog still opens in single-row mode from both states** + +```tsx +const openAssignForRow = useCallback((row: CollectionPoolRow) => { + setAssignDialog({ + mode: "single", + rowId: row.id, + initialDriverId: row.drv_id ?? "", + initialDate: assignedAtToYmd(row.assigned_at), + }); +}, []); +``` + +- [ ] **Step 2: Keep the existing submit handler and remove dead bulk branches only if no longer reachable** + +```ts +const handleAssignDialogSubmit = useCallback(async (payload: AssignDriverSubmitPayload) => { + const { driverId, date } = payload; + if (!assignDialog || assignDialog.mode !== "single") return; + + setBusyId(assignDialog.rowId); + try { + await updateItemStatus(assignDialog.rowId, { + 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]); +``` + +- [ ] **Step 3: Keep the ready toggle path untouched** + +```ts +const handleToggleReady = useCallback(async (itemId: string, ready: boolean) => { + const res = await fetch(`/api/v1/collection-map/items/${itemId}/assign`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ready_for_collection: ready }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); +}, [load]); +``` + +- [ ] **Step 4: Run lint on the touched files** + +Run: `npm run lint -- "src/app/(app)/collection-pool/page.tsx" "src/components/ui/assign-driver-dialog.tsx"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add "src/app/(app)/collection-pool/page.tsx" "src/components/ui/assign-driver-dialog.tsx" +git commit -m "refactor: preserve collection pool actions in unified layout" +``` + +### Task 5: Validate The Redesigned Page End To End + +**Files:** +- Modify: `e2e/collection-pool.spec.ts` +- Modify: `docs/superpowers/plans/2026-07-07-collection-pool-redesign.md` + +- [ ] **Step 1: Add a browser spec only if local seeded data is predictable** + +```ts +import { test, expect, HAS_AUTH_FIXTURE } from "./_helpers"; + +test.describe("collection pool page", () => { + test.skip(!HAS_AUTH_FIXTURE, "E2E auth fixture required"); + + test("renders the unified list shell and ready filter controls", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel(/email/i).fill(process.env.E2E_TEST_EMAIL!); + await page.getByLabel(/password/i).fill(process.env.E2E_TEST_PASSWORD!); + await page.getByRole("button", { name: /sign in|log in|login/i }).click(); + await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 10_000 }); + + await page.goto("/collection-pool"); + await expect(page.getByRole("heading", { name: /collection pool/i })).toBeVisible(); + await expect(page.getByRole("radio", { name: /ready only/i })).toBeVisible(); + await expect(page.locator("[data-od-id='collection-pool-unified-list']")).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Run the optional Playwright spec** + +Run: `npm run e2e -- e2e/collection-pool.spec.ts` +Expected: PASS if seeded data is available, or SKIP if auth fixture/data is unavailable. + +- [ ] **Step 3: Run the full local verification set** + +Run: `npm run test:run -- "src/app/(app)/collection-pool/view-model.test.ts"` +Expected: PASS. + +Run: `npm run lint -- "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" "e2e/collection-pool.spec.ts"` +Expected: PASS. + +- [ ] **Step 4: Perform manual browser validation** + +```text +Open /collection-pool and verify: +1. Assigned and unassigned rows appear in one list. +2. Supplier, item, customer, company, age, and PO are visible on every row. +3. Assignment badges read Unassigned, Assigned today, or Assigned future. +4. Unassigned rows show Assign Driver. +5. Assigned rows show Reschedule. +6. Mark Ready and Mark Awaiting still work. +7. Ready only and Awaiting only filter correctly. +8. Nearby suppliers share the same side marker color without cluster headers. +``` + +- [ ] **Step 5: Commit** + +```bash +git add "e2e/collection-pool.spec.ts" "src/app/(app)/collection-pool/page.tsx" "src/app/(app)/collection-pool/view-model.ts" "src/app/(app)/collection-pool/view-model.test.ts" "docs/superpowers/plans/2026-07-07-collection-pool-redesign.md" +git commit -m "test: validate redesigned collection pool" +``` + +## Self-Review + +- Spec coverage: + - Unified single list: Task 2 and Task 3 + - Required row fields: Task 3 + - Assignment status indicator: Task 3 + - Context-aware actions: Task 3 and Task 4 + - Proximity side marker: Task 1 and Task 2 + - Ready and awaiting filters: Task 2 and Task 3 + - Browser validation: Task 5 +- Placeholder scan: + - Removed `TODO` style language from implementation steps. + - Kept the Playwright file explicitly optional and described the skip condition instead of leaving it vague. +- Type consistency: + - `AssignmentState`, `CollectionPoolViewRow`, `buildVisibleRows`, `buildProximityMarkerMap`, and `getPoolAgeLabel` use the same names across tasks. diff --git a/docs/superpowers/specs/2026-07-07-collection-pool-redesign-design.md b/docs/superpowers/specs/2026-07-07-collection-pool-redesign-design.md new file mode 100644 index 0000000..39a4297 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-collection-pool-redesign-design.md @@ -0,0 +1,253 @@ +# Collection Pool Redesign Design + +Date: 2026-07-07 +Area: `src/app/(app)/collection-pool/page.tsx` +Status: Drafted for review + +## Goal + +Redesign the existing collection pool order view so dispatchers work from one simplified operational list instead of the current multi-layered grouped layout. The new view must reduce visual complexity while preserving the current workflow for readiness toggling and driver assignment. + +## User Problem + +The current collection pool page mixes several mental models at once: + +- assigned items are split into a separate section, +- unassigned items are grouped by supplier, +- proximity grouping adds cluster headers, controls, and hidden isolated groups, +- bulk controls and nested grouping chrome compete with the row-level actions. + +This makes the page harder to scan quickly during dispatch work, especially when the operator needs one answer: what still needs collection, what is already assigned, and what action should happen next. + +## Approved Direction + +Use a single unified operational list with one `order_item` per row. + +This is the chosen direction because it: + +- preserves the current per-item workflow, +- keeps all required details visible without opening subpanels, +- removes the split between assigned and unassigned views, +- retains spatial context through lightweight visual tagging instead of grouped sections. + +## Scope + +In scope: + +- redesign the `Collection Pool` page UI and derived view logic, +- flatten assigned and unassigned rows into one list, +- retain `Mark Awaiting` and `Mark Ready` row actions, +- show required operational fields on every row, +- replace visible driver names with assignment-status indicators, +- replace the current proximity grouping UI with subtle side color markers, +- preserve `Ready only` and `Awaiting only` filtering, +- validate the redesigned page in the browser after implementation. + +Out of scope: + +- changing PocketBase schema, +- changing assignment API contracts, +- changing driver-app workflows, +- redesigning `collection-map`, +- introducing a new bulk-action workflow. + +## Row Model + +Each row continues to represent exactly one `order_item`. + +This keeps all current actions aligned to the existing backend write paths and avoids changing operational semantics. The redesign is a view refactor, not a workflow rewrite. + +## Unified List Structure + +The page should derive one `visibleRows` collection from the loaded `order_items`. + +The unified list includes: + +- items in `item_status = "collection_pool"`, +- items in `item_status = "assigned"`, +- rows with a truthy `drv_id` that still belong in the dispatcher's view until collected. + +Assigned rows remain visible in the same list as unassigned rows. They are differentiated only through assignment-state visuals and their row action label. + +The current separate assigned section, per-driver subgrouping, per-supplier buckets, visible cluster headers, and isolated footer are removed. + +## Sorting + +The unified list should sort by operational usefulness rather than by nested grouping. + +Recommended sort precedence: + +1. Assigned for today +2. Unassigned +3. Assigned for a future date +4. Within each bucket above, older pool items first +5. Stable fallback by supplier name, then PO number, then description + +Rationale: + +- assigned-today items stay visible near the top for same-day operational monitoring, +- unassigned items remain easy to find for active dispatching, +- future assignments stay visible but do not crowd today's work, +- stale items remain easy to escalate. + +## Required Row Content + +Every visible row must show all of the following without expansion: + +- supplier name, +- items scheduled for collection, +- associated customer information, +- parent company details, +- time on list expressed in hours or days, +- PO number. + +Additional supporting details may remain visible if they do not reintroduce clutter, but the required fields above cannot be hidden behind expansion or secondary panels. + +## Assignment Status Indicator + +Visible driver names are removed from the row UI. + +Replace them with a compact assignment-state badge: + +- `Unassigned` +- `Assigned today` +- `Assigned future` + +Badge meaning: + +- `Unassigned`: no active assignment date for the row, +- `Assigned today`: assigned and scheduled for the current day, +- `Assigned future`: assigned with a scheduled date after today. + +The assignment badge is the only primary differentiator between assigned and unassigned rows in the unified list. + +## Ready State + +Keep the existing ready workflow and labels: + +- `Mark Ready` +- `Mark Awaiting` + +The ready state remains visible on every row as a small status indicator and is still directly actionable from the row. + +## Row Actions + +The right side of each row contains the operational actions. + +Action rules: + +- unassigned rows show `Assign Driver`, +- assigned rows show `Reschedule`, +- all rows retain the appropriate `Mark Ready` or `Mark Awaiting` action. + +No separate assigned-only release section should remain in the redesigned layout. If any legacy release-to-pool logic is still required technically, it should not be a dominant layout element and should not recreate the previous split-page structure. + +## Proximity Context + +Remove the current `Group by proximity` control, cluster headers, radius chips, and isolated footer. + +Replace them with subtle side color markers: + +- suppliers within roughly 1-2km of one another receive the same side marker color, +- the marker is passive context only, +- the marker should not create new grouping containers, headers, or toggles. + +Implementation intent: + +- retain useful route-planning context, +- eliminate the visual and interaction overhead of explicit cluster grouping, +- keep the list visually flat. + +## Filters + +Preserve the existing readiness filters exactly: + +- `Ready only` +- `Awaiting only` + +These continue to filter the unified list without changing row structure. + +The general search field also remains, since it already supports supplier, description, PO number, and order number lookup. + +## Layout Guidance + +The redesigned list should read as an operational table-like list rather than nested cards within grouped sections. + +Design goals: + +- consistent column rhythm, +- easy left-to-right scanning, +- minimal vertical chrome, +- clear action target placement, +- assigned and unassigned rows readable in one stream. + +Recommended layout pattern: + +- thin left color rail for proximity context, +- central content area with stable field placement, +- compact badges for ready state and assignment state, +- right-aligned action button group. + +## Implementation Approach + +Refactor `src/app/(app)/collection-pool/page.tsx` toward a flatter data and rendering pipeline. + +Expected changes: + +- replace the current `assignedRows`, `unassignedRows`, `assignedByDriver`, `grouped`, and `renderGroups` view model with a single derived list, +- replace explicit cluster rendering with a lightweight proximity-color mapping helper, +- simplify `PoolRow` so it renders one consistent row shape for all states, +- keep the existing assign dialog and existing PATCH write paths for assignment and ready-state changes, +- remove UI elements that exist only to support the old grouped structure. + +## Error Handling + +Existing error handling behavior should stay intact: + +- failed row updates still surface toast errors, +- failed loads still surface a readable load error and empty state fallback, +- missing purchase-order linkage should still be visible on the affected row. + +## Validation Plan + +After implementation, validate in the browser that: + +1. the page loads without runtime errors, +2. all collection-pool and assigned items appear in one unified list, +3. assignment badges display correctly for unassigned, today, and future rows, +4. supplier, item, customer, company, age, and PO fields render for every row, +5. `Assign Driver` appears only on unassigned rows, +6. `Reschedule` appears only on assigned rows, +7. `Mark Ready` and `Mark Awaiting` still work, +8. `Ready only` and `Awaiting only` filters still work, +9. proximity side markers render without grouped headers or extra controls, +10. the redesigned layout is visibly flatter and easier to scan than the current page. + +## Risks And Guardrails + +Primary risk: + +- flattening the page could accidentally hide useful secondary actions or remove data the operator still depends on. + +Guardrails: + +- keep all required fields visible, +- keep the existing assignment dialog behavior, +- keep ready-state actions unchanged, +- do not split assigned items into a separate section again, +- do not replace one form of grouping clutter with another. + +## Acceptance Criteria + +The redesign is complete when all of the following are true: + +- the collection pool page presents a single unified list, +- assigned items remain visible in that same list, +- driver names are no longer shown, +- assignment state is conveyed by compact visual status, +- required operational details are visible on every row, +- `Assign Driver` and `Reschedule` appear contextually, +- `Mark Ready` and `Mark Awaiting` still function, +- `Ready only` and `Awaiting only` filters are preserved, +- proximity context is reduced to subtle side markers, +- browser validation confirms the simplified layout works end to end. diff --git a/scripts/migrations/2026-07-07-order-items-collection-photo.ts b/scripts/migrations/2026-07-07-order-items-collection-photo.ts new file mode 100644 index 0000000..e7ba288 --- /dev/null +++ b/scripts/migrations/2026-07-07-order-items-collection-photo.ts @@ -0,0 +1,108 @@ +/** + * Migration — add `collection_photo` (single file) to `order_items`. + * + * Why: + * The driver app's Collect flow needs a place to attach the optional + * proof-of-pickup photo (driver takes a snapshot of the goods at the + * supplier, before signing off the row). Today there's no file field + * on `order_items`, so drivers had no way to attach anything from + * the field — operators had to take their word on quantity + * discrepancies without visual context. + * + * Lifecycle: + * - Optional on every collection. Driver can skip it. + * - Single image per row (multi-photo would balloon PB storage + * and isn't part of the current spec). + * - Stamped alongside `qty_collected` / `drv_collected_at` so the + * audit trail ties proof to numbers and time. + * + * Field spec: + * - 10MB max — modern phone cameras run ~3–6MB JPEG; 10MB gives + * headroom for HEIC and burst shots without throttling the UI. + * - image/* mime allowlist. PDF and other docs go elsewhere. + * - maxSelect: 1. A second photo would be a separate feature. + * + * Idempotent — skips if the field already exists. + * + * Run with (from WFDASH_NEXTJS so .env.local is found): + * npx tsx scripts/migrations/2026-07-07-order-items-collection-photo.ts + */ +/* eslint-disable */ +import PocketBase from "pocketbase"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function loadEnvFile(name: string) { + try { + const raw = readFileSync(resolve(process.cwd(), name), "utf8"); + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); + if (m && !process.env[m[1]]) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); + } + } + } catch { + /* fine — rely on shell env */ + } +} +loadEnvFile(".env"); +loadEnvFile(".env.local"); + +const FIELD_NAME = "collection_photo"; + +async function main() { + const url = process.env.NEXT_PUBLIC_POCKETBASE_URL; + const email = process.env.PB_ADMIN_EMAIL; + const password = process.env.PB_ADMIN_PASSWORD; + if (!url || !email || !password) { + throw new Error( + "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).", + ); + } + + const pb = new PocketBase(url); + await pb.collection("_superusers").authWithPassword(email, password); + console.log(`[order-items-collection-photo] Authenticated against ${url}`); + + const col = await pb.collections.getOne("order_items"); + const fields = (col.fields as Array>).slice(); + + if (fields.some((f) => f.name === FIELD_NAME)) { + console.log( + `[order-items-collection-photo] ${FIELD_NAME} already exists — skip field add`, + ); + } else { + fields.push({ + name: FIELD_NAME, + type: "file", + required: false, + presentable: false, + options: { + maxSelect: 1, + maxSize: 10 * 1024 * 1024, // 10MB — phone cameras, room to breathe + mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"], + }, + }); + await pb.collections.update(col.id, { fields }); + console.log( + `[order-items-collection-photo] added \`${FIELD_NAME}\` (file, optional, max 10MB, image/* only) to order_items`, + ); + } + + const fresh = await pb.collections.getOne("order_items"); + const verify = (fresh.fields as Array<{ name: string; type: string }>) + .filter((f) => f.name === FIELD_NAME) + .map((f) => `${f.name}:${f.type}`) + .join(", "); + console.log( + `[order-items-collection-photo] Post-update → ${verify || "FIELD MISSING"}`, + ); + console.log("[order-items-collection-photo] Migration complete."); +} + +main().catch((err) => { + console.error("[order-items-collection-photo] failed:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/src/app/(app)/collection-pool/page.tsx b/src/app/(app)/collection-pool/page.tsx index 143d70d..05d66d9 100644 --- a/src/app/(app)/collection-pool/page.tsx +++ b/src/app/(app)/collection-pool/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Truck, FileText, AlertTriangle, ExternalLink, Search, Users, Calendar, Building2, UserRound, Check, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Truck, FileText, AlertTriangle, ExternalLink, Search, Users, Calendar, Building2, UserRound, Check, Loader2, Layers, RotateCcw } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; @@ -14,7 +14,9 @@ import { type AssignDriverSubmitPayload, } from "@/components/ui/assign-driver-dialog"; import { getPocketBase } from "@/lib/pocketbase"; +import { getMappableSuppliers } from "@/lib/pb-helpers"; import type { Order, OrderItem } from "@/lib/types"; +import { buildClusters, parseCoordinates, type Cluster } from "@/lib/geo"; import { cn } from "@/lib/utils"; type CollectionPoolRow = OrderItem & { @@ -181,6 +183,43 @@ export default function CollectionPoolPage() { // checkbox can flip every row in that bucket atomically. const [selected, setSelected] = useState>(new Set()); + // Proximity-cluster toggle. `clusterOn` defaults ON — the page's + // primary job is "give me stops close together so I can route a + // driver efficiently", so the operator should not have to discover + // a hidden toggle to see that grouping. When ON, a one-shot + // supplier fetch populates `supplierCoords`, and we re-group + // `grouped` by geographic proximity (radius `clusterRadiusKm`). + // Solo suppliers (no neighbours within radius) are rendered bare — + // no cluster header — to avoid adding visual noise to the common + // single-supplier page state. + const [clusterOn, setClusterOn] = useState(true); + const [clusterRadiusKm, setClusterRadiusKm] = useState<2 | 3 | 5 | 10>(3); + // `null` until the first toggle-on; after that, the map stays + // populated across toggles. Suppliers missing coordinates are + // excluded from clustering (rendered as solo buckets). + const [supplierCoords, setSupplierCoords] = useState | null>(null); + // Promise cache so React StrictMode (and Fast Refresh) don't double + // the network call. The Promise itself is stored, not a boolean, so + // a second mount awaits the in-flight fetch instead of starting a + // new one. + const supFetchPromise = useRef | null>(null); + const [supplierCoordsLoading, setSupplierCoordsLoading] = useState(false); + // Isolated-stops footer is collapsed by default. The list shows + // ONLY items that have a neighbour within `clusterRadiusKm`; the + // rest live behind this toggle. Reset to false when the radius + // changes so the operator sees the new cluster shape before + // expanding the rest. + const [showIsolated, setShowIsolated] = useState(false); + // drv_id → { name, email } lookup so the assigned section header + // can show "→ David E." instead of "→ k045yfeze92ckx9". Populated + // by /api/v1/users/lookup on mount. Empty until that fetch lands — + // the fallback renders the id slice the same way the existing row + // "Assigned" badge does. + const [driversById, setDriversById] = useState>({}); + useEffect(() => { + setShowIsolated(false); + }, [clusterRadiusKm]); + // Assign dialog state — one dialog instance at the page level so // the per-row and bulk entry points share the same form chrome. // When `assignDialog` is non-null the dialog renders; the value @@ -214,12 +253,31 @@ export default function CollectionPoolPage() { setRows(null); try { const pb = getPocketBase(); + // The pool view shows every order_item that hasn't been + // collected yet: items the supplier confirmed into the pool + // (`item_status = "collection_pool"`) PLUS items already + // assigned to a driver that are still on the driver's run + // (`item_status = "assigned"`). The driver app hides an + // assigned row once `drv_status` reaches `collected`, so the + // dispatcher needs to see those rows here until that flip + // happens — otherwise a driver mid-run has no dispatcher-side + // visibility (a driver calls in sick mid-route, dispatcher + // can't reassign because the row is invisible). Collected / + // po_generated / pending rows stay out of this query. We + // sort by `updated` descending so the most recently + // touched row (often a re-assignment) lands at the top of + // its bucket, then the renderer splits unassigned vs + // assigned-within-bucket for visual ordering. const items = await pb.collection("order_items").getFullList({ - filter: 'item_status = "collection_pool"', - // Oldest-first: the only write that lands an item in the pool is the - // SMTP outcome callback, so `updated` IS the pool-entry moment. Sort - // ascending so stale items naturally rise to the top of each bucket - // and the operator sees the oldest backlog first without scanning. + filter: 'item_status = "collection_pool" || item_status = "assigned"', + // Oldest-first: the only write that lands a pool item in the + // pool is the SMTP outcome callback, so `updated` IS the + // pool-entry moment. Sort ascending so stale items naturally + // rise to the top of each bucket and the operator sees the + // oldest backlog first without scanning. Assigned rows are + // re-grouped at the TOP of the page by the renderer (not by + // the sort), so their age order still reads correctly within + // the assigned section. sort: "updated", expand: "purchase_order.supplier,order.company,order.customer", }); @@ -237,6 +295,91 @@ export default function CollectionPoolPage() { void load(); }, [load]); + // Resolve drv_id → driver display name for the "Currently + // assigned to drivers" section header chips. We pull from the + // server-side /api/v1/users/lookup endpoint rather than calling + // `pb.collection("users").getFullList()` directly — PB's users + // collection has listRule = "id = @request.auth.id" so a regular + // client can only see themselves. The lookup endpoint is admin + // authed server-side and returns the minimum shape we need. + // Re-run only on hard remount — the response is server-cached. + 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 map: Record = {}; + for (const u of data) { + if (!u.id) continue; + map[u.id] = { name: u.name || u.email || u.id, email: u.email }; + } + setDriversById(map); + }) + .catch(() => { + // Silent — the assigned section still works without names; + // it just falls back to the truncated drv_id slice. + }); + return () => { + cancelled = true; + }; + }, []); + + // One-shot supplier-coordinate fetch — fires the first time the + // operator flips the proximity-cluster toggle on, AND re-fetches + // on a hard remount (which is what keeps happening in React + // dev StrictMode and on Fast Refresh). The cost is one ~1-second + // `getMappableSuppliers` call returning 449 records; cheap enough + // to re-run rather than juggle a `useRef` lock that the previous + // version kept getting into a deadlock with StrictMode (the lock + // set true on the first mount, the cleanup cancelled the first + // run mid-flight, and the second mount bailed out without ever + // setting `supplierCoords` — chips stayed disabled forever). + useEffect(() => { + if (!clusterOn) return; + if (typeof window === "undefined") return; + if (supFetchPromise.current) return; + setSupplierCoordsLoading(true); + supFetchPromise.current = (async () => { + try { + const suppliers = await getMappableSuppliers(); + const next = new Map(); + for (const s of suppliers) { + const coords = parseCoordinates(s.coordinates); + if (!coords) continue; + if (!s.id) continue; + next.set(s.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}`); + } finally { + setSupplierCoordsLoading(false); + } + })(); + }, [clusterOn]); + + // Disabled-state for the radius chip row. When the supplier + // coordinate fetch hasn't completed yet, has failed, or returned + // zero mappable suppliers, clicking the chips is a no-op — the + // render path always falls into "all solo buckets". Surfacing + // this state on the chips themselves (with a tooltip explaining + // why) prevents the operator from feeling the click did nothing. + // Declared up here so the chip row JSX below can read it; the + // cluster summary that shows "X clusters · Y items" lives next + // to `renderGroups` further down because it needs that memo. + const radiusChipsDisabled = + !supplierCoords; + const radiusChipsDisabledReason = !supplierCoords + ? supplierCoordsLoading + ? "Loading supplier locations…" + : "Couldn't load supplier locations — radius has no effect." + : ""; + // Free-text search across supplier name, item description, PO number, // and source order number. Case-insensitive, whitespace-tolerant. // Single source of truth so an operator doesn't have to pick the right @@ -268,6 +411,62 @@ export default function CollectionPoolPage() { return result; }, [rows, filterText, readyFilter]); + // Split the filtered set into "already assigned to a driver" vs + // "still in the pool". The dispatcher needs to see assigned rows + // here too — a driver calling in sick mid-route, or the + // dispatcher re-planning because a different driver is closer + // to a stop, both demand a way to find and amend rows that are + // currently riding with another driver. Assigned rows render at + // the top of the page, visually greyed out so the eye lands on + // the dispatchable backlog first. The radius/proximity filter + // below only operates on the unassigned subset — assigned rows + // are already locked to a driver's run, they don't need to be + // routed against the pool's geography. Detection mirrors the + // truthy check inside : + // item_status === "assigned" OR drv_id is set. + const { assignedRows, unassignedRows } = useMemo(() => { + if (!filtered) return { assignedRows: null, unassignedRows: null }; + const assigned: CollectionPoolRow[] = []; + const unassigned: CollectionPoolRow[] = []; + for (const row of filtered) { + const isAssigned = Boolean(row.drv_id) || row.item_status === "assigned"; + (isAssigned ? assigned : unassigned).push(row); + } + return { assignedRows: assigned, unassignedRows: unassigned }; + }, [filtered]); + + // Group the assigned subset by driver (drv_id) so the page can + // show one sub-section per driver inside the "Currently + // assigned to drivers" panel. Empty / missing drv_id falls + // into an "unassigned-id" bucket — legacy / hand-typed rows + // exist where drv_id was set without an item_status flip. + const assignedByDriver = useMemo(() => { + if (!assignedRows) return [] as Array<{ driverId: string; items: CollectionPoolRow[] }>; + const map = new Map(); + for (const row of assignedRows) { + const id = row.drv_id || "__no_drv_id__"; + const bucket = map.get(id) ?? []; + bucket.push(row); + map.set(id, bucket); + } + return Array.from(map.entries()).map(([driverId, items]) => ({ + driverId, + items, + })); + }, [assignedRows]); + + // Subset of `selected` that lives inside `assignedRows`. Used + // by the bulk-release header button to count only the rows + // the operator actually selected for "back to pool", separate + // from any rows they have selected in the unassigned section + // (which would only need a BulkAssignDriverButton). + const assignedSelectedIds = useMemo(() => { + if (!assignedRows) return [] as string[]; + const ids = new Set(assignedRows.map((r) => r.id)); + return Array.from(selected).filter((id) => ids.has(id)); + }, [assignedRows, selected]); + const assignedSelectedCount = assignedSelectedIds.length; + // Drop rows whose ids are no longer in the loaded set (keeps the // checkbox state clean after a bulk-assign + refresh cycle). useEffect(() => { @@ -310,13 +509,18 @@ export default function CollectionPoolPage() { }); }, []); - // Drop rows whose ids are no longer in the loaded set (keeps the - // checkbox state clean after a bulk-assign + refresh cycle). - // Group by supplier for the default view + // Group the UNASSIGNED subset by supplier. Assigned rows are kept + // out of the cluster/proximity pipeline — they're already locked + // to a driver's run, the dispatcher doesn't need to re-route them + // against the pool's geography. Each supplier becomes a bucket; + // the per-supplier section chrome (header strip + per-bucket + // select-all checkbox) is rendered below in the JSX. The proximity + // toggle above turns these buckets into clusters when there are + // 2+ suppliers within `clusterRadiusKm` of each other. const grouped = useMemo(() => { - if (!filtered) return null; + if (!unassignedRows) return null; const buckets = new Map(); - filtered.forEach((r) => { + unassignedRows.forEach((r) => { const supplierId = r.expand?.purchase_order?.supplier ?? "unknown"; const arr = buckets.get(supplierId) ?? []; arr.push(r); @@ -327,7 +531,134 @@ export default function CollectionPoolPage() { items[0]?.expand?.purchase_order?.expand?.supplier?.suppliername ?? supplierId; return { supplierId, name, items }; }); - }, [filtered]); + }, [unassignedRows]); + + // Proximity-cluster view. Flat list of "render groups": each entry + // is either a cluster (rendered with the coloured bar header — only + // when the cluster has 2+ members at the current radius) or a + // suppressed-bucket entry that the renderer collapses into a single + // "isolated stops" footer at the bottom of the list. The radius + // chip is therefore a real list filter: clicking 2km / 3km / 5km / + // 10km shrinks or grows the visible list as items join or leave + // clusters. + // + // When the toggle is OFF we skip the cluster stage and treat every + // bucket as visible — that's the legacy "flat list" view, and the + // cluster summary label makes that explicit. + // + // `buildClusters` requires a coord on every input, so we feed it + // only the buckets that have a resolved coord. Buckets without + // coords (or where the coord is missing from `supplierCoords`) + // bypass the cluster stage and go straight to the isolated footer. + type BucketGroup = { supplierId: string; name: string; items: CollectionPoolRow[] }; + type RenderGroup = + | { kind: "cluster"; key: string; color: string; supplierCount: number; buckets: BucketGroup[] } + | { kind: "solo"; bucket: BucketGroup }; + + const renderGroups = useMemo(() => { + if (!grouped) return null; + if (!clusterOn || !supplierCoords) { + return grouped.map((bucket) => ({ kind: "solo" as const, bucket })); + } + const withCoords = grouped + .map((bucket) => { + const coord = supplierCoords.get(bucket.supplierId); + return coord ? { bucket, lat: coord.lat, lng: coord.lng } : null; + }) + .filter((x): x is { bucket: BucketGroup; lat: number; lng: number } => x !== null); + const bucketsWithoutCoords = grouped.filter( + (b) => !supplierCoords.has(b.supplierId) + ); + + const clusters = buildClusters(withCoords, clusterRadiusKm); + + // Walk the clusters and bucket them into "show" (size >= 2) vs + // "isolated" (size 1, no neighbour within the current radius). + // We also keep the bucket-insertion order for the visible list + // so a supplier cluster of A,B,C still renders in that order + // even though `buildClusters` walks them in input order. + type InternalCluster = Cluster<{ bucket: BucketGroup; lat: number; lng: number }>; + const visibleClusters: InternalCluster[] = []; + const isolatedBuckets: BucketGroup[] = []; + for (const cluster of clusters) { + if (cluster.items.length >= 2) { + visibleClusters.push(cluster); + } else { + isolatedBuckets.push(cluster.items[0].bucket); + } + } + // Items without coords join the isolated bucket list — they + // can't participate in proximity filtering because we don't + // know where they are. + isolatedBuckets.push(...bucketsWithoutCoords); + + const result: RenderGroup[] = visibleClusters.map((cluster) => { + const memberBuckets = cluster.items.map((it) => it.bucket); + return { + kind: "cluster" as const, + key: cluster.id, + color: cluster.color, + supplierCount: memberBuckets.length, + buckets: memberBuckets, + }; + }); + // Append a single virtual "isolated" group at the end so the + // renderer can render one collapse bar for all of them. When + // there are zero isolated items we skip the entry entirely so + // we don't show a 0-count footer. + if (isolatedBuckets.length > 0) { + result.push({ + kind: "cluster" as const, + key: "__isolated__", + color: "#9CA3AF", // neutral grey — explicitly NOT a cluster colour + supplierCount: isolatedBuckets.length, + buckets: isolatedBuckets, + }); + } + return result; + }, [grouped, clusterOn, supplierCoords, clusterRadiusKm]); + + // Live summary for the radius chip row. The operator needs visible + // feedback that clicking 2km / 3km / 5km / 10km had an effect — + // when the radius shrinks, items fall out of clusters and move + // to the "isolated" footer; when it grows, isolated items rejoin + // the visible clusters. The summary always reports both numbers + // so a click always changes at least one of them. + const clusterSummaryLabel = useMemo(() => { + if (!clusterOn || !supplierCoords) return ""; + if (!renderGroups) return ""; + type BucketGroup = { supplierId: string; name: string; items: CollectionPoolRow[] }; + type RenderCluster = { kind: "cluster"; key: string; color: string; supplierCount: number; buckets: BucketGroup[] }; + type RenderGroup = RenderCluster | { kind: "solo"; bucket: BucketGroup }; + const groups: RenderGroup[] = renderGroups; + const visibleClusters = groups.filter( + (g): g is RenderCluster => g.kind === "cluster" && g.key !== "__isolated__" + ); + const isolatedGroup = groups.find( + (g): g is RenderCluster => g.kind === "cluster" && g.key === "__isolated__" + ); + const itemsInVisibleClusters = visibleClusters.reduce( + (sum, g) => + sum + g.buckets.reduce((s: number, b: BucketGroup) => s + b.items.length, 0), + 0 + ); + const isolatedItemCount = isolatedGroup + ? isolatedGroup.buckets.reduce( + (s: number, b: BucketGroup) => s + b.items.length, + 0 + ) + : 0; + if (visibleClusters.length === 0 && isolatedItemCount === 0) { + return `No items in pool at ${clusterRadiusKm}km`; + } + if (visibleClusters.length === 0) { + return `0 visible clusters at ${clusterRadiusKm}km · ${isolatedItemCount} isolated (no neighbour within ${clusterRadiusKm}km)`; + } + if (isolatedItemCount === 0) { + return `${visibleClusters.length} cluster${visibleClusters.length === 1 ? "" : "s"} · ${itemsInVisibleClusters} item${itemsInVisibleClusters === 1 ? "" : "s"} within ${clusterRadiusKm}km · all visible`; + } + return `${visibleClusters.length} cluster${visibleClusters.length === 1 ? "" : "s"} · ${itemsInVisibleClusters} item${itemsInVisibleClusters === 1 ? "" : "s"} within ${clusterRadiusKm}km · ${isolatedItemCount} isolated`; + }, [clusterOn, supplierCoords, renderGroups, clusterRadiusKm]); const updateItemStatus = useCallback( async ( @@ -431,16 +762,87 @@ export default function CollectionPoolPage() { [updateItemStatus] ); + /** + * Return an already-assigned row back to the pool (un-assign + * without sending it back to procurement). Used by the "Return + * to pool" action on the "Currently assigned to drivers" + * section — the dispatcher flips the row's `item_status` back to + * `"collection_pool"`, clears its `drv_id`, and bumps the + * `updated` timestamp so the row visually leaves the assigned + * section and re-joins the unassigned grouping below it. + * + * Goes through the same `/api/.../assign` route used by + * AssignDriverDialog for consistency — that route already + * encodes "driverId empty + date set → return to pool". + */ const handleReturnToPool = useCallback( async (itemId: string) => { - await updateItemStatus(itemId, { - item_status: "po_generated", - drv_status: "unassigned", - drv_id: "", - assigned_at: "", - }); + setBusyId(itemId); + try { + const today = new Date().toISOString().slice(0, 10); + const res = await fetch( + `/api/v1/collection-map/items/${itemId}/assign`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ driverId: "", date: today }), + } + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `HTTP ${res.status}`); + } + toast.success(`Returned to pool.`); + await load(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message); + } finally { + setBusyId(null); + } }, - [updateItemStatus] + [load] + ); + + /** + * Bulk-return a set of rows to the pool. Used by the + * "Release N to pool" button in the assigned section header + * when the dispatcher needs to clean up multiple stale + * assignments at once (driver called in sick, route got + * restructured, etc.). Loops sequentially so any individual + * failure is contained rather than blowing up the whole batch. + */ + const handleBulkReturnToPool = useCallback( + async (itemIds: string[]) => { + if (itemIds.length === 0) return; + let ok = 0; + let failed = 0; + const today = new Date().toISOString().slice(0, 10); + for (const id of itemIds) { + try { + setBusyId(id); + const res = await fetch( + `/api/v1/collection-map/items/${id}/assign`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ driverId: "", date: today }), + } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + ok += 1; + } catch { + failed += 1; + } finally { + setBusyId(null); + } + } + if (ok > 0) toast.success(`${ok} item${ok === 1 ? "" : "s"} released back to the pool.`); + if (failed > 0) toast.error(`${failed} failed to release.`); + setSelected(new Set()); + await load(); + }, + [load] ); /** @@ -523,8 +925,9 @@ export default function CollectionPoolPage() { Collection Pool

    - Order items awaiting driver pickup from their supplier. Assign a driver, mark collected when the - goods arrive, or send back to procurement if there's a problem. + Order items awaiting driver pickup — whether still in the dispatchable + pool or currently riding with a driver. Assign, release, or send to + procurement from here.

    @@ -580,6 +983,89 @@ export default function CollectionPoolPage() { ))}
    + + + · + + + {/* Proximity-cluster toggle. When ON, the pool is re-grouped + by supplier location and each multi-supplier cluster gets + a subtle header. Default OFF so today's view is untouched. */} + + + {clusterOn && ( +
    +
    + {([2, 3, 5, 10] as const).map((km) => ( + + ))} +
    + {clusterOn && supplierCoords && ( + + {clusterSummaryLabel} + + )} +
    + )} @@ -599,80 +1085,450 @@ export default function CollectionPoolPage() { )} - {rows !== null && rows.length > 0 && grouped && grouped.length === 0 && ( - - )} + {/* Filters applied but BOTH the assigned and the unassigned + subsets came up empty. (We don't use `grouped.length === 0` + any more because that's the unassigned-only slice and + would false-positive — a dispatcher with 10 assigned rows + and 0 unassigned would still see "no items match".) */} + {rows !== null && + rows.length > 0 && + assignedRows && + unassignedRows && + assignedRows.length === 0 && + unassignedRows.length === 0 && ( + + )} - {rows !== null && grouped && grouped.length > 0 && ( -
    - {grouped.map((bucket) => { - const palette = supplierPalette(bucket.supplierId); - const itemIds = bucket.items.map((it) => it.id); - const bucketAllSelected = isBucketFullySelected(itemIds); - const bucketSelectedCount = itemIds.filter((id) => selected.has(id)).length; + {rows !== null && assignedRows && assignedRows.length > 0 && ( +
    +
    +
    + +

    + Currently assigned to drivers +

    + {assignedRows.length} +
    +

    + Locked onto a driver's run — visible here for re-dispatch + or to pick up if a driver calls in sick. Rows stay in the + pool until the driver marks them collected. +

    + {/* Bulk-release back to pool. Counts every currently + selected assigned row so the dispatcher can sweep + multiple stale assignments back into the dispatchable + backlog in one click. Disabled when nothing's selected. */} +
    + {assignedSelectedCount > 0 && ( + + )} +
    +
    + + {/* One sub-section per driver — the dispatcher sees at a + glance which driver has what, and any single driver's + full pile can be released with one click. Supplier + palette is intentionally desaturated to neutral grey + so the assigned section reads visually as "secondary + context, not the main pool". */} + {assignedByDriver.map((group) => { + const driverName = driversById[group.driverId]?.name ?? null; + const driverEmail = driversById[group.driverId]?.email; + const bucketIds = group.items.map((r) => r.id); + const bucketSelected = bucketIds.filter((id) => + selected.has(id) + ); return ( -
    -
    -
    -
    - -
    -
    - {bucketSelectedCount > 0 && ( - <> - selected.has(r.id))} - palette={palette} - onOpenBulk={openAssignForBulk} - /> - selected.has(r.id))} - palette={palette} - onBulkToggleReady={handleBulkToggleReady} - /> - - )} -
    +
    +
    + + + {driverName ?? `Driver ${group.driverId.slice(0, 8)}`} + + {driverEmail && ( + · + )} + {driverEmail && ( + {driverEmail} + )} + {group.items.length} + {bucketSelected.length > 0 && ( + + )}
      - {bucket.items.map((row) => ( + {group.items.map((row) => ( ))}
    -
    +
    ); })} + + )} + + {rows !== null && renderGroups && renderGroups.length > 0 && ( +
    + {/* Visible clusters: items that have a neighbour within the + current radius. The radius chip is now a real filter — + changing it shrinks or grows this list in lockstep with + the "isolated" footer below. */} + {renderGroups + .filter((g) => g.kind === "cluster" && g.key !== "__isolated__") + .map((group) => { + // Member buckets inside a cluster reuse the same per-bucket + // section chrome as solo buckets. Collect them up front so + // we can render the cluster header once AND so the cluster + // header can compute total item count + item ids for the + // "select all N items in cluster" affordance. + const memberBuckets = + group.kind === "cluster" ? group.buckets : [group.bucket]; + const memberItemIds = memberBuckets.flatMap((b) => + b.items.map((it) => it.id) + ); + const memberItemCount = memberItemIds.length; + const clusterSelectedCount = memberItemIds.filter((id) => + selected.has(id) + ).length; + const clusterFullySelected = + memberItemCount > 0 && + memberItemIds.every((id) => selected.has(id)); + return ( +
    + {group.kind === "cluster" && ( + // The cluster header is the operator's "this is a + // batchable group" affordance. A coloured bar (tied + // to the cluster's assigned colour), supplier + item + // counts, and a single-click "select every item in + // this cluster" toggle so the operator can dispatch + // all nearby stops to one driver without clicking + // through every supplier's checkbox individually. +
    + +
    +
    + Nearby cluster · {group.supplierCount} supplier + {group.supplierCount === 1 ? "" : "s"} · {memberItemCount} item + {memberItemCount === 1 ? "" : "s"} +
    +
    + Within {clusterRadiusKm}km of each other — route together +
    +
    + + {clusterSelectedCount > 0 && ( + + )} +
    + )} +
    + {memberBuckets.map((bucket) => { + const palette = supplierPalette(bucket.supplierId); + const itemIds = bucket.items.map((it) => it.id); + const bucketAllSelected = isBucketFullySelected(itemIds); + const bucketSelectedCount = itemIds.filter((id) => + selected.has(id) + ).length; + return ( +
    +
    +
    +
    + +
    +
    + {bucketSelectedCount > 0 && ( + <> + selected.has(r.id))} + palette={palette} + onOpenBulk={openAssignForBulk} + /> + selected.has(r.id))} + palette={palette} + onBulkToggleReady={handleBulkToggleReady} + /> + + )} +
    +
    +
      + {bucket.items.map((row) => ( + + ))} +
    +
    + ); + })} +
    +
    + ); + })} + {/* Isolated-stops footer: items with no neighbour within the + current radius are hidden by default so the operator's + eye lands on the dispatchable clusters first. Click to + expand and reveal the supplier buckets that didn't make + it into any cluster. */} + {(() => { + type BucketGroup = { supplierId: string; name: string; items: CollectionPoolRow[] }; + type RenderCluster = { kind: "cluster"; key: string; color: string; supplierCount: number; buckets: BucketGroup[] }; + type RenderGroup = RenderCluster | { kind: "solo"; bucket: BucketGroup }; + const groups: RenderGroup[] = renderGroups; + const isolatedGroup = groups.find( + (g): g is RenderCluster => + g.kind === "cluster" && g.key === "__isolated__" + ); + if (!isolatedGroup) return null; + const memberBuckets: BucketGroup[] = isolatedGroup.buckets; + const memberItemIds: string[] = memberBuckets.flatMap((b: BucketGroup) => + b.items.map((it: CollectionPoolRow) => it.id) + ); + const memberItemCount = memberItemIds.length; + const isolatedSelectedCount = memberItemIds.filter((id: string) => + selected.has(id) + ).length; + return ( +
    +
    + +
    +
    + {memberItemCount} isolated stop + {memberItemCount === 1 ? "" : "s"} · no neighbour within {clusterRadiusKm}km +
    +
    + Hidden from the dispatch list — click to reveal +
    +
    + + {isolatedSelectedCount > 0 && ( + + )} +
    + {showIsolated && ( +
    + {memberBuckets.map((bucket: BucketGroup) => { + const palette = supplierPalette(bucket.supplierId); + const itemIds: string[] = bucket.items.map((it: CollectionPoolRow) => it.id); + const bucketAllSelected = isBucketFullySelected(itemIds); + const bucketSelectedCount = itemIds.filter((id: string) => + selected.has(id) + ).length; + return ( +
    +
    +
    +
    + +
    +
    + {bucketSelectedCount > 0 && ( + <> + selected.has(r.id))} + palette={palette} + onOpenBulk={openAssignForBulk} + /> + selected.has(r.id))} + palette={palette} + onBulkToggleReady={handleBulkToggleReady} + /> + + )} +
    +
    +
      + {bucket.items.map((row) => ( + + ))} +
    +
    + ); + })} +
    + )} +
    + ); + })()}
    )} @@ -709,14 +1565,25 @@ function PoolRow({ palette, busy, selected: isSelected, + dimmed = false, onToggleSelected, onOpenAssign, onToggleReady, + onReturnToPool, }: { row: CollectionPoolRow; palette: typeof SUPPLIER_PALETTE[number]; busy: boolean; selected: boolean; + /** + * When true, the row renders with reduced opacity and a desaturated + * stripe — used by the "Currently assigned to drivers" section to + * visually separate dispatchable rows from rows locked to a + * driver's run. The row is still interactive (checkbox, reschedule, + * return-to-pool), only the visual emphasis is dimmed so the eye + * lands on the unassigned backlog first. + */ + dimmed?: boolean; onToggleSelected: (id: string) => void; /** * Called when the operator clicks "Assign driver" on this row. @@ -730,6 +1597,14 @@ function PoolRow({ * row should land at (`true` = ready, `false` = not yet). */ onToggleReady: (id: string, ready: boolean) => void; + /** + * Called when the operator clicks "Return to pool" on this row. + * Only rendered when `dimmed` (i.e. the row is currently + * assigned to a driver) — that's the only state where + * releasing is meaningful. Not shown for unassigned rows since + * they haven't been claimed yet. + */ + onReturnToPool?: (id: string) => void; }) { const orderQty = row.order_qty ?? row.quantity ?? 0; const customerQty = row.quantity ?? 0; @@ -755,10 +1630,19 @@ function PoolRow({ // Subtle row tint so vertical scans stick to a supplier without eyes // darting back to the section header. Tinted card lifts behind the // checkbox; white defaults feel cluttered once >20 rows land. - const rowBg = isSelected - ? cn(palette.tint, "ring-1", palette.ring) - : "bg-card hover:bg-muted/40"; - const stripe = cn("absolute inset-y-0 left-0 w-1 rounded-l-md", palette.stripe); + // When `dimmed` is true (the "Currently assigned to drivers" + // section), the row swaps to a muted-grey card with a desaturated + // stripe so the eye lands on the dispatchable backlog first. The + // row stays interactive — checkbox, Reschedule and Return-to-pool + // all still work, we only dampen the visual emphasis. + const rowBg = dimmed + ? "bg-muted/40 opacity-70 hover:opacity-95" + : isSelected + ? cn(palette.tint, "ring-1", palette.ring) + : "bg-card hover:bg-muted/40"; + const stripe = dimmed + ? "absolute inset-y-0 left-0 w-1 rounded-l-md bg-muted-foreground/40" + : cn("absolute inset-y-0 left-0 w-1 rounded-l-md", palette.stripe); // Show the assignment chip only when this row has been touched by // a previous assign cycle. We detect this two ways — the row may @@ -964,6 +1848,25 @@ function PoolRow({ )} {row.ready_for_collection ? "Mark awaiting" : "Mark ready"} + {/* "Return to pool" only renders for rows that actually + belong in the assigned section (dimmed AND on a driver). + The handler exists on every row, but we gate the button + on dimmed+isAssigned so it never appears in the + dispatchable backlog — unassigned rows have nothing to + "return". */} + {dimmed && isAssigned && onReturnToPool && ( + + )} 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..b96c825 --- /dev/null +++ b/src/app/(app)/collection-pool/view-model.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + buildProximityMarkerMap, + buildVisibleRows, + 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("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"]); + }); +}); + +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")); + }); +}); 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..be31044 --- /dev/null +++ b/src/app/(app)/collection-pool/view-model.ts @@ -0,0 +1,103 @@ +import { buildClusters } from "@/lib/geo"; + +export type AssignmentState = "unassigned" | "assigned_today" | "assigned_future"; + +export type CollectionPoolViewRow = { + id: string; + description?: string; + updated?: string; + item_status?: string; + drv_id?: string; + assigned_at?: string; + ready_for_collection?: boolean | null; + supplierId: string; + supplierName?: string; + companyName?: string; + customerName?: string; + poNumber?: 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 buildVisibleRows( + rows: CollectionPoolViewRow[], + options: { todayYmd: string; searchText: string; readyFilter: "all" | "ready" | "unready" }, +): CollectionPoolViewRow[] { + const query = options.searchText.trim().toLowerCase(); + + 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.customerName ?? "", + row.companyName ?? "", + ].some((value) => value.toLowerCase().includes(query)); + }) + .sort((a, b) => { + 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.supplierName ?? ""}|${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare( + `${b.supplierName ?? ""}|${b.poNumber ?? ""}|${b.description ?? ""}`, + ); + }); +} + +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/components/sidebar.tsx b/src/components/sidebar.tsx index 3afa552..4a9d6a6 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -70,15 +70,27 @@ const quoteItems: NavEntry[] = [ { href: "/quotes/archived", label: "Archived", icon: Archive }, ]; +// Assign Drivers used to live here as its own sidebar entry, but the +// driver-assignment workflow is now fully embedded in the Collection +// Pool page (per-row Assign + per-bucket bulk Assign). Keeping a +// redundant top-level link only invited confusion, so it's gone. +// The full /orders/assign route is still reachable by URL for the +// older admin flow but is no longer surfaced in the nav. const orderItems: NavEntry[] = [ { href: "/orders/new", label: "New Order", icon: Plus }, { href: "/orders", label: "Order List", icon: ShoppingCart }, - { href: "/orders/assign", label: "Assign Drivers", icon: Truck }, ]; const operationsItems: NavEntry[] = [ { href: "/collection-pool", label: "Collection Pool", icon: Package }, { href: "/collection-map", label: "Collection Map", icon: MapPin }, +]; + +// "Other" is the catch-all bucket for surfaces that aren't part of the +// daily collection workflow but still need a home in the nav. Kept +// separate from Operations so the operator's eye can stay on the two +// pool/map entries that actually drive the day's work. +const otherItems: NavEntry[] = [ { href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList }, { href: "/repairs", label: "Repairs", icon: Wrench }, ]; @@ -262,6 +274,12 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) { pathname={pathname} onLinkClick={onLinkClick} /> +