+ );
+}
+```
+
+- [ ] **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