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:
parent
17ae928331
commit
b9828def38
11 changed files with 2317 additions and 84 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
39145
|
||||
631
docs/superpowers/plans/2026-07-07-collection-pool-redesign.md
Normal file
631
docs/superpowers/plans/2026-07-07-collection-pool-redesign.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
108
scripts/migrations/2026-07-07-order-items-collection-photo.ts
Normal file
108
scripts/migrations/2026-07-07-order-items-collection-photo.ts
Normal 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
107
src/app/(app)/collection-pool/view-model.test.ts
Normal file
107
src/app/(app)/collection-pool/view-model.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
103
src/app/(app)/collection-pool/view-model.ts
Normal file
103
src/app/(app)/collection-pool/view-model.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue