feat(collection-pool): add unified redesigned collection pool view

Remove redundant Assign Drivers top-level navigation link since driver assignment is now embedded directly in the collection pool page per-row, and add an Other nav section for leftover navigation items
Extract collection pool view logic into a dedicated view-model module with full Vitest test coverage for assignment state classification, row sorting, pool age labeling, and proximity marker grouping
Flatten the collection pool page into a single unified list replacing the old grouped assigned/unassigned layout, with updated row UI showing all required operational details and context-aware actions
Add database migration to add optional `collection_photo` field to order_items for driver pickup proof photos
Add complete design documentation, planning artifacts, and brainstorming session files for the collection pool redesign project
This commit is contained in:
SJCoetz 2026-07-07 20:49:31 +02:00
parent 17ae928331
commit b9828def38
11 changed files with 2317 additions and 84 deletions

View file

@ -0,0 +1,105 @@
<h2>Option 3: Hybrid Table With Expandable Detail</h2>
<p class="subtitle">Compact top-level rows with the full operational detail revealed only when a row is expanded.</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">Collection Pool · Hybrid compact list</div>
<div class="mockup-body" style="padding:0">
<div style="display:grid;grid-template-columns:6px 1.2fr 1fr 0.7fr 0.8fr 0.9fr 1.1fr;gap:0;border-bottom:1px solid #e5e7eb;background:#f8fafc;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#64748b;">
<div style="background:#7c3aed;"></div>
<div style="padding:12px;">Supplier / Items</div>
<div style="padding:12px;">Customer / Company</div>
<div style="padding:12px;">Pool Age</div>
<div style="padding:12px;">PO Number</div>
<div style="padding:12px;">Status</div>
<div style="padding:12px;">Actions</div>
</div>
<div style="display:grid;grid-template-columns:6px 1.2fr 1fr 0.7fr 0.8fr 0.9fr 1.1fr;border-bottom:1px solid #e5e7eb;background:white;align-items:center;">
<div style="background:#7c3aed;min-height:68px;"></div>
<div style="padding:12px;">
<div style="font-weight:700;color:#0f172a;">Bearings International</div>
<div style="font-size:13px;color:#334155;">2 x Wheel hub kits</div>
</div>
<div style="padding:12px;">
<div style="font-size:13px;color:#0f172a;">Mogalakwena North Pit</div>
<div style="font-size:12px;color:#64748b;">Anglo American Platinum</div>
</div>
<div style="padding:12px;font-weight:600;color:#b45309;">4d</div>
<div style="padding:12px;font-family:monospace;color:#0f172a;">PO-18294</div>
<div style="padding:12px;">
<span style="display:inline-flex;align-items:center;gap:6px;border-radius:999px;background:#dbeafe;color:#1d4ed8;padding:4px 10px;font-size:12px;font-weight:700;">Assigned today</span>
</div>
<div style="padding:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button class="mock-button" style="padding:8px 10px;">Reschedule</button>
<button class="mock-button" style="padding:8px 10px;background:#111827;">Mark Ready</button>
</div>
</div>
<div style="border-bottom:1px solid #e5e7eb;background:#f8fafc;padding:14px 18px 16px 24px;">
<div style="font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#64748b;margin-bottom:10px;">Expanded detail panel</div>
<div style="display:grid;grid-template-columns:repeat(4, minmax(0,1fr));gap:12px;">
<div style="border:1px solid #e2e8f0;border-radius:12px;background:white;padding:12px;">
<div style="font-size:11px;text-transform:uppercase;color:#94a3b8;">Items scheduled</div>
<div style="margin-top:6px;font-weight:700;color:#0f172a;">Wheel hub kits, 2 units</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:12px;background:white;padding:12px;">
<div style="font-size:11px;text-transform:uppercase;color:#94a3b8;">Assignment</div>
<div style="margin-top:6px;font-weight:700;color:#0f172a;">Assigned for today</div>
<div style="font-size:12px;color:#64748b;">Driver name hidden from main row</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:12px;background:white;padding:12px;">
<div style="font-size:11px;text-transform:uppercase;color:#94a3b8;">Supplier cluster</div>
<div style="margin-top:6px;font-weight:700;color:#0f172a;">Purple side marker</div>
<div style="font-size:12px;color:#64748b;">Within 2 km of related suppliers</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:12px;background:white;padding:12px;">
<div style="font-size:11px;text-transform:uppercase;color:#94a3b8;">Extra actions</div>
<div style="margin-top:6px;font-weight:700;color:#0f172a;">Mark Awaiting</div>
<div style="font-size:12px;color:#64748b;">Secondary action stays inside the panel</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:6px 1.2fr 1fr 0.7fr 0.8fr 0.9fr 1.1fr;border-bottom:1px solid #e5e7eb;background:white;align-items:center;">
<div style="background:#16a34a;min-height:68px;"></div>
<div style="padding:12px;">
<div style="font-weight:700;color:#0f172a;">Hydraulic Supplies Co.</div>
<div style="font-size:13px;color:#334155;">1 x Seal kit assembly</div>
</div>
<div style="padding:12px;">
<div style="font-size:13px;color:#0f172a;">Rustenburg Workshop</div>
<div style="font-size:12px;color:#64748b;">Wolfram Fleet Services</div>
</div>
<div style="padding:12px;font-weight:600;color:#0f172a;">6h</div>
<div style="padding:12px;font-family:monospace;color:#0f172a;">PO-18301</div>
<div style="padding:12px;">
<span style="display:inline-flex;align-items:center;gap:6px;border-radius:999px;background:#f3f4f6;color:#374151;padding:4px 10px;font-size:12px;font-weight:700;">Unassigned</span>
</div>
<div style="padding:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button class="mock-button" style="padding:8px 10px;">Assign Driver</button>
<button class="mock-button" style="padding:8px 10px;">Mark Awaiting</button>
</div>
</div>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pros">
<h4>What it improves</h4>
<ul>
<li>Keeps the list visually compact when there are many rows.</li>
<li>Lets operators focus on a narrower primary scan line.</li>
<li>Still supports the same actions on each row.</li>
</ul>
</div>
<div class="cons">
<h4>Why I did not recommend it</h4>
<ul>
<li>Some required context shifts into the expanded panel instead of staying visible all the time.</li>
<li>Dispatchers need an extra click to inspect fuller customer and item detail.</li>
<li>It adds interaction complexity instead of removing it.</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View file

@ -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"}

View file

@ -0,0 +1 @@
39145

View file

@ -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> = {}): 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<CollectionPoolViewRow, "item_status" | "drv_id" | "assigned_at">, todayYmd: string): AssignmentState {
const hasAssignment = Boolean(row.drv_id) || row.item_status === "assigned";
if (!hasAssignment) return "unassigned";
const assignedYmd = row.assigned_at ? row.assigned_at.slice(0, 10) : "";
return assignedYmd > todayYmd ? "assigned_future" : "assigned_today";
}
export function getPoolAgeLabel(updatedAt: string | undefined, now = new Date()): string {
if (!updatedAt) return "—";
const updated = new Date(updatedAt);
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<string, string>();
clusters.forEach((cluster, index) => {
const token = MARKER_TOKENS[index % MARKER_TOKENS.length];
cluster.items.forEach((item) => {
result.set(item.supplierId, token);
});
});
return result;
}
```
- [ ] **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<string, string>();
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 && (
<ul className="space-y-2" data-od-id="collection-pool-unified-list">
{visibleRows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busyId === row.id}
markerToken={supplierMarkerMap.get(row.expand?.purchase_order?.supplier ?? "unknown")}
onOpenAssign={openAssignForRow}
onToggleReady={handleToggleReady}
/>
))}
</ul>
)}
```
- [ ] **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
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Ready</span>
<div role="radiogroup" aria-label="Filter by ready-for-collection status" className="inline-flex rounded-md border bg-card shadow-sm">
{(["all", "ready", "unready"] as const).map((opt) => (
<button
key={opt}
type="button"
role="radio"
aria-checked={readyFilter === opt}
onClick={() => setReadyFilter(opt)}
className={cn(
"px-3 py-1 text-xs capitalize transition-colors",
readyFilter === opt ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
>
{opt === "all" ? "All" : opt === "ready" ? "Ready only" : "Awaiting only"}
</button>
))}
</div>
</div>
```
- [ ] **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 (
<li className="grid grid-cols-[6px_minmax(0,1.3fr)_minmax(0,1fr)_auto_auto] overflow-hidden rounded-md border bg-card shadow-sm">
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
<div className="min-w-0 p-3">
<p className="truncate text-sm font-semibold">{row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"}</p>
<p className="truncate text-sm text-foreground">{row.description || "(no description)"}</p>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{row.expand?.order?.expand?.customer?.customer ?? "No customer"}</span>
<span>·</span>
<span>{row.expand?.order?.expand?.company?.company ?? "No company"}</span>
</div>
</div>
<div className="p-3 text-sm">
<div className="font-mono text-xs text-muted-foreground">{row.expand?.purchase_order?.po_number ?? "No PO"}</div>
<div className="mt-1 text-sm">{getPoolAgeLabel(row.updated)}</div>
</div>
<div className="flex items-center gap-2 p-3">
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", assignmentBadge.className)}>
{assignmentBadge.label}
</span>
<span className={cn(
"inline-flex rounded-full px-2 py-0.5 text-xs font-medium",
row.ready_for_collection ? "bg-emerald-100 text-emerald-800 ring-1 ring-emerald-200/60" : "bg-amber-100 text-amber-800 ring-1 ring-amber-200/60",
)}>
{row.ready_for_collection ? "Ready" : "Awaiting"}
</span>
</div>
<div className="flex flex-col items-stretch gap-2 p-3">
<Button size="sm" variant={isAssigned ? "outline" : "default"} onClick={() => onOpenAssign(row)} disabled={busy}>
{isAssigned ? "Reschedule" : "Assign Driver"}
</Button>
<Button size="sm" variant={row.ready_for_collection ? "outline" : "default"} onClick={() => onToggleReady(row.id, !row.ready_for_collection)} disabled={busy}>
{row.ready_for_collection ? "Mark Awaiting" : "Mark Ready"}
</Button>
</div>
</li>
);
}
```
- [ ] **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<OrderItem>);
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.

View file

@ -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.

View file

@ -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<Record<string, unknown>>).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);
});

File diff suppressed because it is too large Load diff

View file

@ -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> = {}): 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"));
});
});

View file

@ -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<AssignmentState, number> = {
assigned_today: 0,
unassigned: 1,
assigned_future: 2,
};
const MARKER_TOKENS = ["marker-blue", "marker-emerald", "marker-violet", "marker-amber"] as const;
export function getAssignmentState(
row: Pick<CollectionPoolViewRow, "item_status" | "drv_id" | "assigned_at">,
todayYmd: string,
): AssignmentState {
const hasAssignment = Boolean(row.drv_id) || row.item_status === "assigned";
if (!hasAssignment) return "unassigned";
const assignedYmd = row.assigned_at ? row.assigned_at.slice(0, 10) : "";
return assignedYmd > todayYmd ? "assigned_future" : "assigned_today";
}
export function getPoolAgeLabel(updatedAt: string | undefined, now = new Date()): string {
if (!updatedAt) return "-";
const updated = new Date(updatedAt);
if (Number.isNaN(updated.getTime())) return "-";
const diffMs = Math.max(0, now.getTime() - updated.getTime());
const hours = Math.floor(diffMs / 3_600_000);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export function 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<string, string> {
const clusters = buildClusters(suppliers, radiusKm);
const result = new Map<string, string>();
clusters.forEach((cluster, index) => {
const token = MARKER_TOKENS[index % MARKER_TOKENS.length];
cluster.items.forEach((item) => {
result.set(item.supplierId, token);
});
});
return result;
}

View file

@ -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}
/>
<NavSection
label="Other"
items={otherItems}
pathname={pathname}
onLinkClick={onLinkClick}
/>
</nav>
</ScrollArea>