WFDASH-WEBAPP/plugin-source/collection-pool-supplier-groups-bulk-assign-primary-time-in-list-note-previe-mrgu6r8f/SKILL.md
SJCoetz 046683c506 feat: add tangerine theme, new docs and plugin stubs
Add full Tangerine brand theme with light and dark mode variants to the app theme system.
Add four new design and integration documents for collection pool, map and driver app workflows.
Add initial scaffolding for three new plugin source directories for the collection pool bulk assignment feature.
2026-07-14 11:48:10 +02:00

1258 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Collection Pool: supplier groups, bulk assign, primary time-in-list, note preview — 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:** Reorganise `/collection-pool` into collapsible supplier sections with bulk-assign (whole-section or selective), make the time-in-list dominant in `text-primary`, and replace the `title`-attribute Notes chip with a proper hover/focus tooltip.
**Architecture:** Helper `buildSupplierGroups` lives in `view-model.ts`; the sort flips to `(supplierName, assignmentState, updated)` so the group builder can slice the sorted array. New `SupplierGroup` component (kept in `page.tsx`) renders the header strip (dot · supplier · count · PO eyebrow · chevron · Assign-all button) plus collapsible body. A second `AssignDriverDialog` instance handles bulk with `bulkCount`. The existing `PoolRow` gets two new props (`selected`, `onToggleSelected`) and a per-row checkbox.
**Tech Stack:** Next.js 16 App Router (read `node_modules/next/dist/docs/` before any Next-specific code), React 18, TypeScript, Tailwind, shadcn/ui, PocketBase admin client, lucide-react, sonner, Vitest.
**Spec:** [docs/superpowers/specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md](../specs/2026-07-10-collection-pool-supplier-groups-and-notes-design.md)
**Pre-flight:**
- [ ] **Read the spec end-to-end** so the helper signatures, sort tiebreakers, and bulk-merge semantics are locked in your head.
- [ ] **Skim `node_modules/next/dist/docs/`** for anything that affects Client Component state patterns (this codebase is "NOT the Next.js you know" per `AGENTS.md`).
- [ ] **Re-read `page.tsx`** (663 lines) — `PoolRow` props, the colour-stripe helper, the existing single-row `AssignDriverDialog` mount, and the `load()` + `updateItemStatus` patterns.
---
## File Structure
| File | Responsibility | Touched? |
| --- | --- | --- |
| `src/app/(app)/collection-pool/view-model.ts` | Add `CollectionPoolGroup` type + `buildSupplierGroups` helper; flip `buildVisibleRows` sort to lead with `supplierName`. | Modify |
| `src/app/(app)/collection-pool/view-model.test.ts` | Pin the new sort tiebreaker order; cover `buildSupplierGroups` (empty, fold, eyebrow, marker token). | Modify |
| `src/app/(app)/collection-pool/page.tsx` | Add `selectedIds`, `collapsedSuppliers`, `bulkAssignDialog` state; add `SupplierGroup` + `PoolRow` updates (time-in-list, notes tooltip, checkbox); wire bulk dialog and sequential PATCH handler; replace flat `<ul>` with grouped `<section>`s. | Modify |
| `src/app/(app)/collection-pool/page.test.tsx` | Cover selection toggling, section select-all, Assign-all opens bulk dialog with `bulkCount=N`, notes tooltip visibility, time-in-list colour class. | Modify |
| `src/app/(app)/collection-pool/components.tsx` (NEW, conditional) | If `page.tsx` exceeds 30 KB after Task 11, split `PoolRow` + `SupplierGroup` here. | Create (conditional) |
---
## Task 1: Add `buildSupplierGroups` helper (TDD)
**Files:**
- Modify: `src/app/(app)/collection-pool/view-model.test.ts`
- Modify: `src/app/(app)/collection-pool/view-model.ts`
- [ ] **Step 1: Append failing tests**
Append to `src/app/(app)/collection-pool/view-model.test.ts` (the existing `makeRow` helper above is reused):
```ts
import { buildSupplierGroups, type CollectionPoolGroup } from "./view-model";
describe("buildSupplierGroups", () => {
it("returns an empty array for empty input", () => {
expect(buildSupplierGroups([], new Map())).toEqual([]);
});
it("folds rows by supplierId in input order", () => {
const rows = [
makeRow({ id: "a", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-1" }),
makeRow({ id: "b", supplierId: "sup-2", supplierName: "Beta", poNumber: "PO-2" }),
makeRow({ id: "c", supplierId: "sup-1", supplierName: "Acme", poNumber: "PO-3" }),
];
const groups = buildSupplierGroups(rows, new Map());
expect(groups.map((g) => g.supplierId)).toEqual(["sup-1", "sup-2"]);
expect(groups[0].rows.map((r) => r.id)).toEqual(["a", "c"]);
expect(groups[1].rows.map((r) => r.id)).toEqual(["b"]);
});
it("captures the top two PO numbers and reports the remaining count", () => {
const rows = [
makeRow({ supplierId: "sup-1", poNumber: "PO-1" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-2" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-3" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-4" }),
makeRow({ supplierId: "sup-1", poNumber: "PO-4" }), // duplicate PO
];
const [group] = buildSupplierGroups(rows, new Map());
expect(group.topPoNumbers).toEqual(["PO-1", "PO-2"]);
expect(group.remainingPoCount).toBe(2);
});
it("uses marker token from supplierMarkerMap", () => {
const map = new Map([["sup-1", "marker-blue"]]);
const [group] = buildSupplierGroups(
[makeRow({ supplierId: "sup-1" })],
map,
);
expect(group.markerToken).toBe("marker-blue");
});
it("returns empty eyebrow when no rows have a PO number", () => {
const [group] = buildSupplierGroups(
[makeRow({ supplierId: "sup-1", poNumber: "" })],
new Map(),
);
expect(group.topPoNumbers).toEqual([]);
expect(group.remainingPoCount).toBe(0);
});
});
```
- [ ] **Step 2: Run tests — expect FAIL**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/view-model.test'
```
Expected: 5 new tests fail with "buildSupplierGroups is not a function".
- [ ] **Step 3: Implement `buildSupplierGroups`**
Append to `src/app/(app)/collection-pool/view-model.ts`:
```ts
export type CollectionPoolGroup = {
supplierId: string;
supplierName: string;
/** Cluster colour token from the proximity map (may be undefined). */
markerToken?: string;
/** First two distinct PO numbers for the header eyebrow. */
topPoNumbers: string[];
/** Number of distinct POs beyond the top two. */
remainingPoCount: number;
/** Rows belonging to this group, in render order. */
rows: CollectionPoolViewRow[];
};
/**
* Folds already-sorted rows into per-supplier groups, preserving input
* order. The caller is responsible for sorting rows by `supplierName`
* (use `buildVisibleRows` first); this helper does not re-sort.
*/
export function buildSupplierGroups(
rows: CollectionPoolViewRow[],
supplierMarkerMap: Map<string, string>,
): CollectionPoolGroup[] {
const groups: CollectionPoolGroup[] = [];
const bySupplier = new Map<string, CollectionPoolGroup>();
for (const row of rows) {
let group = bySupplier.get(row.supplierId);
if (!group) {
group = {
supplierId: row.supplierId,
supplierName: row.supplierName ?? "",
markerToken: supplierMarkerMap.get(row.supplierId),
topPoNumbers: [],
remainingPoCount: 0,
rows: [],
};
bySupplier.set(row.supplierId, group);
groups.push(group);
}
group.rows.push(row);
}
for (const group of groups) {
const uniquePos = Array.from(
new Set(group.rows.map((r) => r.poNumber ?? "").filter(Boolean)),
);
group.topPoNumbers = uniquePos.slice(0, 2);
group.remainingPoCount = Math.max(0, uniquePos.length - 2);
}
return groups;
}
```
- [ ] **Step 4: Run tests — expect PASS**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/view-model.test'
```
Expected: all tests green.
- [ ] **Step 5: Commit**
```bash
git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts'
git commit -m "feat(collection-pool): add buildSupplierGroups helper"
```
---
## Task 2: Flip sort tiebreaker to lead with `supplierName`
**Files:**
- Modify: `src/app/(app)/collection-pool/view-model.test.ts`
- Modify: `src/app/(app)/collection-pool/view-model.ts`
- [ ] **Step 1: Append failing tests**
Append to `src/app/(app)/collection-pool/view-model.test.ts`:
```ts
describe("buildVisibleRows — supplier-first sort (operational mode)", () => {
const todayYmd = "2026-07-10";
it("sorts by supplierName first when sortMode is operational", () => {
const rows = buildVisibleRows(
[
makeRow({ id: "beta", supplierName: "Beta" }),
makeRow({ id: "acme", supplierName: "Acme" }),
makeRow({ id: "alpha", supplierName: "Alpha" }),
],
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" },
);
expect(rows.map((r) => r.id)).toEqual(["acme", "alpha", "beta"]);
});
it("falls through to assignmentState within the same supplier", () => {
const rows = buildVisibleRows(
[
makeRow({
id: "acme-unassigned",
supplierName: "Acme",
item_status: "collection_pool",
}),
makeRow({
id: "acme-today",
supplierName: "Acme",
item_status: "assigned",
drv_id: "drv-1",
assigned_at: `${todayYmd}T00:00:00.000Z`,
}),
],
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "operational" },
);
expect(rows.map((r) => r.id)).toEqual(["acme-today", "acme-unassigned"]);
});
it("nearby mode still uses cluster prefix before supplier", () => {
const rows = buildVisibleRows(
[
makeRow({
id: "beta-cluster-a",
supplierName: "Beta",
clusterSortKey: "cluster-a",
}),
makeRow({
id: "acme-cluster-b",
supplierName: "Acme",
clusterSortKey: "cluster-b",
}),
makeRow({
id: "acme-cluster-a",
supplierName: "Acme",
clusterSortKey: "cluster-a",
}),
],
{ todayYmd, searchText: "", readyFilter: "all", sortMode: "nearby" },
);
expect(rows.map((r) => r.id)).toEqual([
"acme-cluster-a",
"beta-cluster-a",
"acme-cluster-b",
]);
});
});
```
- [ ] **Step 2: Run tests — expect FAIL**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/view-model.test'
```
Expected: 3 new tests fail. The two existing `buildVisibleRows sort modes` tests should still pass (they don't depend on supplier ordering); the original `"sorts assigned today first"` test may start to fail because it expects today-first but the new sort is supplier-first. **Note that failure and proceed — it's expected and will be reconciled when the production change lands in Step 3.**
- [ ] **Step 3: Update `buildVisibleRows` sort**
In `src/app/(app)/collection-pool/view-model.ts`, replace the existing `.sort((a, b) => { ... })` callback inside `buildVisibleRows` with:
```ts
.sort((a, b) => {
if (sortMode === "nearby") {
const clusterDiff = (a.clusterSortKey ?? "zzz").localeCompare(b.clusterSortKey ?? "zzz");
if (clusterDiff !== 0) return clusterDiff;
}
// Supplier name is the primary group key. Rows in the same
// supplier are then ordered by assignment urgency (today →
// unassigned → future), then by updated, then by PO + description.
const supplierDiff = (a.supplierName ?? "").localeCompare(b.supplierName ?? "");
if (supplierDiff !== 0) return supplierDiff;
const stateDiff =
ASSIGNMENT_SORT_ORDER[getAssignmentState(a, options.todayYmd)] -
ASSIGNMENT_SORT_ORDER[getAssignmentState(b, options.todayYmd)];
if (stateDiff !== 0) return stateDiff;
const updatedA = a.updated ?? "";
const updatedB = b.updated ?? "";
if (updatedA !== updatedB) return updatedA.localeCompare(updatedB);
return `${a.poNumber ?? ""}|${a.description ?? ""}`.localeCompare(
`${b.poNumber ?? ""}|${b.description ?? ""}`,
);
});
```
- [ ] **Step 4: Run tests — expect PASS**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/view-model.test'
```
Expected: all tests green. The pre-existing `"sorts assigned today first, then unassigned, then future"` test will now fail because supplier is the primary key; the expected order across multiple suppliers is supplier-alphabetical regardless of assignment state. **If that test was already updated by a previous commit, leave it alone; otherwise update its expectation to include supplier-distinct fixtures so the assertion still holds.** The simplest fix:
```ts
// Old assertion was: ["today", "unassigned", "future"] with one supplier.
// Update fixture so all three rows share a supplier OR split into
// distinct suppliers and assert alphabetical. Easiest: keep one
// supplier and assert the assignment order.
makeRow({ id: "future", supplierName: "Same", ... }),
makeRow({ id: "unassigned", supplierName: "Same", ... }),
makeRow({ id: "today", supplierName: "Same", ... }),
// Expect ["today", "unassigned", "future"]
```
- [ ] **Step 5: Commit**
```bash
git add 'src/app/(app)/collection-pool/view-model.ts' 'src/app/(app)/collection-pool/view-model.test.ts'
git commit -m "feat(collection-pool): sort by supplier name first"
```
---
## Task 3: Time-in-list primary colour + "In pool since" sub-label
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx:607`
- [ ] **Step 1: Update the `PoolRow` order-detail cell**
In `src/app/(app)/collection-pool/page.tsx`, replace the order-detail cell body that currently contains the time-in-list label (around line 607):
```tsx
<div className="mt-1 text-base font-semibold text-primary">
{getPoolAgeLabel(row.updated)}
</div>
{row.updated && (
<div className="mt-0.5 text-[11px] text-muted-foreground">
In pool since {new Date(row.updated).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
})}
</div>
)}
```
Keep the surrounding markup (`<div className="min-w-0 text-sm">`, the Order/PO labels above, the `Scheduled {date}` line below) untouched.
- [ ] **Step 2: Run the existing page suite**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/page'
```
Expected: existing tests still pass (this change adds markup but doesn't alter data flow).
- [ ] **Step 3: Commit**
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "style(collection-pool): promote time-in-list to text-primary"
```
---
## Task 4: Notes chip — proper hover/focus tooltip
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx:625-632`
- [ ] **Step 1: Replace the native `title` chip with a `group/notes` tooltip**
In `src/app/(app)/collection-pool/page.tsx`, replace the existing notes chip block:
```tsx
{row.notes && row.notes.trim().length > 0 && (
<span className="group/notes relative inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Notes
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-20 mt-1 hidden w-72 rounded-md border bg-popover px-3 py-2 text-xs font-normal normal-case tracking-normal text-popover-foreground shadow-md group-hover/notes:block group-focus-within/notes:block"
>
{row.notes.length > 280 ? `${row.notes.slice(0, 280)}…` : row.notes}
</span>
</span>
)}
```
If `bg-popover` / `text-popover-foreground` are not defined in the project's `globals.css`, fall back to `bg-background text-foreground` (these always resolve).
- [ ] **Step 2: Run the existing page suite**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/page'
```
Expected: existing tests still pass. If any test pinned the old `title` attribute, update the selector to match the new tooltip (e.g. `findByRole("tooltip")`).
- [ ] **Step 3: Commit**
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): replace notes title with hover tooltip"
```
---
## Task 5: Selection state + per-row checkbox on `PoolRow`
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx` (state, props, JSX, render call)
- [ ] **Step 1: Add `selectedIds` state + handlers**
In `CollectionPoolPage`, alongside the existing `useState` calls (around line 106–116), add:
```ts
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleRowSelected = useCallback((rowId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(rowId)) next.delete(rowId);
else next.add(rowId);
return next;
});
}, []);
const toggleSectionSelected = useCallback((rowIds: string[]) => {
setSelectedIds((prev) => {
const allSelected = rowIds.every((id) => prev.has(id));
const next = new Set(prev);
if (allSelected) {
for (const id of rowIds) next.delete(id);
} else {
for (const id of rowIds) next.add(id);
}
return next;
});
}, []);
```
- [ ] **Step 2: Extend `PoolRow` props and signature**
Update the `PoolRow` props type and signature in the same file (around line 551):
```tsx
function PoolRow({
row,
busy,
markerToken,
todayYmd,
selected,
onToggleSelected,
onOpenAssign,
onToggleReady,
}: {
row: CollectionPoolVisibleRow;
busy: boolean;
markerToken?: string;
todayYmd: string;
selected: boolean;
onToggleSelected: (rowId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
}) {
```
- [ ] **Step 3: Add the checkbox and row tint to `PoolRow`'s JSX**
In `PoolRow`'s `<li>` element (around line 579), apply the selection tint and add a checkbox at the start of the content grid. Replace the opening markup:
```tsx
<li
className={cn(
"grid grid-cols-[6px_minmax(0,1fr)]",
selected && "bg-primary/5",
)}
aria-busy={busy}
>
<span className={cn("min-h-full", markerTokenToClassName(markerToken))} aria-hidden />
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-2">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelected(row.id)}
disabled={busy}
aria-label={`Select ${row.description ?? "item"} at ${row.supplierName ?? "unknown supplier"}`}
className="mt-1 size-4 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{row.supplierName}</p>
<p className="mt-1 truncate text-sm text-foreground">{row.description || "(no description)"}</p>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{row.customerName || "No customer"}</span>
<span className="text-border">/</span>
<span>{row.companyName || "No company"}</span>
</div>
{!row.purchase_order && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200/80 bg-amber-50/70 px-2.5 py-2 text-[11px] leading-5 text-amber-900">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>This row has no purchase order link. The PO may have been deleted.</span>
</div>
)}
</div>
</div>
{/* ...rest of the columns unchanged... */}
</div>
</li>
```
(The supplier/description/customer/company/warning block was previously the direct first child of the inner `<div className="grid ...">`. It is now nested inside a new wrapper `<div className="flex min-w-0 items-start gap-2">` that hosts the checkbox.)
- [ ] **Step 4: Update the call site (still the flat `<ul>` for now)**
In the rendering block around line 488–500, update the `PoolRow` invocation:
```tsx
<ul className="divide-y" data-od-id="collection-pool-unified-list">
{visibleRows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busyId === row.id}
markerToken={supplierMarkerMap.get(row.supplierId)}
todayYmd={todayYmd}
selected={selectedIds.has(row.id)}
onToggleSelected={toggleRowSelected}
onOpenAssign={openAssignForRow}
onToggleReady={handleToggleReady}
/>
))}
</ul>
```
- [ ] **Step 5: Run page suite + commit**
```bash
npm run test:run -- 'src/app/(app)/collection-pool/page'
```
Expected: existing tests still pass (PoolRow is exercised via the page).
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): per-row checkbox + selection state"
```
---
## Task 6: `SupplierGroup` component (renders header + collapsible body)
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx` (new `SupplierGroup` function)
- [ ] **Step 1: Add the `SupplierGroup` component**
Append a new `SupplierGroup` function inside `page.tsx`, after `PoolRow`. It renders the section header strip and the collapsible row body:
```tsx
function SupplierGroup({
group,
busy,
todayYmd,
selectedIds,
collapsed,
onToggleRowSelected,
onToggleSectionSelected,
onToggleCollapsed,
onOpenAssign,
onOpenBulkAssign,
onToggleReady,
}: {
group: CollectionPoolGroup;
busy: boolean;
todayYmd: string;
selectedIds: Set<string>;
collapsed: boolean;
onToggleRowSelected: (rowId: string) => void;
onToggleSectionSelected: (rowIds: string[]) => void;
onToggleCollapsed: (supplierId: string) => void;
onOpenAssign: (row: CollectionPoolVisibleRow) => void;
onOpenBulkAssign: (supplierId: string, supplierName: string, rowIds: string[]) => void;
onToggleReady: (row: CollectionPoolVisibleRow, ready: boolean) => void;
}) {
const rowIds = group.rows.map((r) => r.id);
const selectedCount = rowIds.filter((id) => selectedIds.has(id)).length;
const allSelected = selectedCount === rowIds.length && rowIds.length > 0;
const someSelected = selectedCount > 0 && !allSelected;
const allAssigned = group.rows.every(
(r) => getAssignmentState(r, todayYmd) !== "unassigned",
);
const eyebrowText = (() => {
if (group.topPoNumbers.length === 0) return null;
const head = group.topPoNumbers.map((p) => `PO ${p}`).join(", ");
return group.remainingPoCount > 0
? `${head} (+${group.remainingPoCount} more)`
: head;
})();
const assignLabel = allSelected
? `Assign ${rowIds.length} stops`
: selectedCount > 0
? `Assign ${selectedCount} selected`
: `Assign all ${rowIds.length}`;
const onAssignClick = () => {
const ids = allSelected || selectedCount === 0 ? rowIds : rowIds.filter((id) => selectedIds.has(id));
onOpenBulkAssign(group.supplierId, group.supplierName, ids);
};
return (
<section
aria-labelledby={`supplier-${group.supplierId}`}
className="border-b last:border-b-0"
>
<header className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5">
<span
className={cn("size-2.5 shrink-0 rounded-full", markerTokenToClassName(group.markerToken))}
aria-hidden
/>
<h3
id={`supplier-${group.supplierId}`}
className="text-sm font-semibold text-foreground"
>
{group.supplierName || "Unknown supplier"}
</h3>
<span className="text-xs text-muted-foreground">
· {group.rows.length} {group.rows.length === 1 ? "stop" : "stops"}
{eyebrowText ? <> · {eyebrowText}</> : null}
</span>
<div className="ml-auto flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() => onToggleSectionSelected(rowIds)}
disabled={busy}
className="size-4 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`Select all ${group.rows.length} stops at ${group.supplierName || "unknown supplier"}`}
/>
Select all
</label>
<Button
size="sm"
onClick={onAssignClick}
disabled={busy || allAssigned}
title={allAssigned ? "Every stop at this supplier is already assigned" : undefined}
>
<Truck className="mr-1.5 size-3.5" />
{assignLabel}
</Button>
<button
type="button"
onClick={() => onToggleCollapsed(group.supplierId)}
aria-expanded={!collapsed}
aria-controls={`supplier-${group.supplierId}-body`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronDown className={cn("size-4 transition-transform", collapsed && "-rotate-90")} />
</button>
</div>
</header>
<div
id={`supplier-${group.supplierId}-body`}
className={cn(
"grid overflow-hidden transition-[grid-template-rows] duration-150 ease-out",
collapsed ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
)}
>
<ul className="divide-y min-h-0">
{group.rows.map((row) => (
<PoolRow
key={row.id}
row={row}
busy={busy}
markerToken={group.markerToken}
todayYmd={todayYmd}
selected={selectedIds.has(row.id)}
onToggleSelected={onToggleRowSelected}
onOpenAssign={onOpenAssign}
onToggleReady={onToggleReady}
/>
))}
</ul>
</div>
</section>
);
}
```
- [ ] **Step 2: Add the missing imports**
At the top of `page.tsx`, extend the lucide-react import:
```tsx
import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react";
```
And update the `./view-model` imports so `buildSupplierGroups`, `CollectionPoolGroup`, and `getAssignmentBadge` are all present:
```tsx
import {
buildProximityMarkerMap,
buildSupplierGroups,
buildVisibleRows,
type CollectionPoolGroup,
type CollectionPoolSortMode,
getAssignmentBadge,
getAssignmentState,
getPoolAgeLabel,
} from "./view-model";
```
(`getAssignmentBadge` is still consumed by `PoolRow`'s status pill — keep it.)
- [ ] **Step 3: Run typecheck**
```bash
npx tsc --noEmit
```
Expected: clean. If `Type 'Set<string>' is not assignable to ...` errors fire on `selectedIds`, the `useState<Set<string>>(new Set())` should already be inferring — double-check the import.
- [ ] **Step 4: Commit (component only — not wired yet)**
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): add SupplierGroup component"
```
---
## Task 7: Replace flat `<ul>` with grouped `<section>`s
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx` (rendering block + state)
- [ ] **Step 1: Add `collapsedSuppliers` state**
In `CollectionPoolPage`, next to `selectedIds`:
```ts
const [collapsedSuppliers, setCollapsedSuppliers] = useState<Set<string>>(new Set());
const toggleCollapsed = useCallback((supplierId: string) => {
setCollapsedSuppliers((prev) => {
const next = new Set(prev);
if (next.has(supplierId)) next.delete(supplierId);
else next.add(supplierId);
return next;
});
}, []);
```
- [ ] **Step 2: Add `openBulkAssign` handler stub**
Below `openAssignForRow`, add a stub that just opens state — the actual submit handler comes in Task 9. (The stub is intentional so Task 8 can wire the button without Task 9 needing to be done first.)
```ts
const [bulkAssignDialog, setBulkAssignDialog] = useState<{
supplierId: string;
supplierName: string;
rowIds: string[];
} | null>(null);
const openBulkAssign = useCallback(
(supplierId: string, supplierName: string, rowIds: string[]) => {
if (rowIds.length === 0) return;
setBulkAssignDialog({ supplierId, supplierName, rowIds });
},
[],
);
const closeBulkAssign = useCallback(() => setBulkAssignDialog(null), []);
```
- [ ] **Step 3: Compute `groups` in the page**
Below `visibleRows`, add:
```tsx
const groups = useMemo<CollectionPoolGroup[] | null>(() => {
if (!visibleRows) return null;
return buildSupplierGroups(visibleRows, supplierMarkerMap);
}, [visibleRows, supplierMarkerMap]);
```
- [ ] **Step 4: Replace the flat-list rendering**
Replace the `<ul>` block (around line 488–500 in the current `page.tsx`) with:
```tsx
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)_auto_auto] gap-4 border-b bg-muted/20 px-4 py-2 pl-[22px] text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground lg:grid">
<span>Collection Item</span>
<span>Order Detail</span>
<span>Status</span>
<span className="text-right">Actions</span>
</div>
{groups === null ? (
<div className="space-y-2 p-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-20 w-full animate-pulse rounded-md bg-muted/30"
aria-label="Loading..."
/>
))}
</div>
) : groups.length === 0 ? (
<EmptyState message="No items match the current filters." />
) : (
groups.map((group) => (
<SupplierGroup
key={group.supplierId}
group={group}
busy={busyId !== null}
todayYmd={todayYmd}
selectedIds={selectedIds}
collapsed={collapsedSuppliers.has(group.supplierId)}
onToggleRowSelected={toggleRowSelected}
onToggleSectionSelected={toggleSectionSelected}
onToggleCollapsed={toggleCollapsed}
onOpenAssign={openAssignForRow}
onOpenBulkAssign={openBulkAssign}
onToggleReady={handleToggleReady}
/>
))
)}
</div>
```
Note: the empty-states at the top level (`rows === null` skeleton, `rows.length === 0` empty, `visibleRows.length === 0` empty) now collapse into the new conditional inside the container — delete the previous three separate early-return blocks (around lines 460–478) to avoid double-rendering.
- [ ] **Step 5: Run typecheck + page suite**
```bash
npx tsc --noEmit
npm run test:run -- 'src/app/(app)/collection-pool/page'
```
Expected: typecheck clean; page tests still pass. If a test pinned the old `<ul data-od-id="collection-pool-unified-list">`, update the selector to find rows by data-od-id on the section or fall back to a more stable test query.
- [ ] **Step 6: Commit**
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): render supplier-grouped sections"
```
---
## Task 8: Wire bulk-assign dialog mount
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx` (second `AssignDriverDialog` mount)
- [ ] **Step 1: Stub the submit handler + bulkBusy state**
Just below `handleAssignDialogSubmit`, add:
```ts
const [bulkBusy, setBulkBusy] = useState(false);
const handleBulkAssignSubmit = useCallback(
async (_payload: AssignDriverSubmitPayload) => {
// Full implementation in Task 9.
},
[],
);
```
- [ ] **Step 2: Add the bulk-assign dialog JSX**
Just below the existing single-row `<AssignDriverDialog />` (around line 504), add:
```tsx
<AssignDriverDialog
open={bulkAssignDialog !== null}
onOpenChange={(open) => {
if (!open) closeBulkAssign();
}}
onSubmit={handleBulkAssignSubmit}
bulkCount={bulkAssignDialog?.rowIds.length ?? 1}
eyebrow={
bulkAssignDialog ? (
<span className="inline-flex items-center gap-2">
<Truck className="size-3.5 text-muted-foreground" />
{bulkAssignDialog.supplierName || "Unknown supplier"}
<span className="text-muted-foreground">·</span>
{bulkAssignDialog.rowIds.length} stops
</span>
) : undefined
}
busy={bulkBusy}
/>
```
- [ ] **Step 3: Run typecheck + commit**
```bash
npx tsc --noEmit
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): wire bulk-assign dialog mount"
```
---
## Task 9: Sequential bulk PATCH handler
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx` (`handleBulkAssignSubmit` body)
- [ ] **Step 1: Replace the stub with the real implementation**
In `page.tsx`, replace the empty `handleBulkAssignSubmit` with:
```ts
const handleBulkAssignSubmit = useCallback(
async (payload: AssignDriverSubmitPayload) => {
if (!bulkAssignDialog) return;
const { driverId, date } = payload;
const { rowIds, supplierName } = bulkAssignDialog;
setBulkBusy(true);
try {
const pb = getPocketBase();
let successCount = 0;
let firstError: string | null = null;
// Sequential, not parallel: any failure leaves earlier
// successes committed (matches the single-row PATCH semantics).
for (const id of rowIds) {
try {
await pb.collection("order_items").update(id, {
item_status: "assigned",
drv_id: driverId,
drv_status: "assigned",
assigned_at: `${date}T00:00:00.000Z`,
} as Partial<OrderItem>);
successCount++;
} catch (err) {
if (!firstError) {
firstError = err instanceof Error ? err.message : String(err);
}
}
}
// Clear selections for this supplier on success (partial or full).
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of rowIds) next.delete(id);
return next;
});
closeBulkAssign();
if (firstError) {
toast.error(
`Assigned ${successCount} of ${rowIds.length} stops at ${supplierName || "supplier"}. First error: ${firstError}`,
);
} else {
toast.success(`Assigned ${successCount} stops at ${supplierName || "supplier"}.`);
}
await load();
} finally {
setBulkBusy(false);
}
},
[bulkAssignDialog, closeBulkAssign, load],
);
```
- [ ] **Step 2: Run typecheck + page suite**
```bash
npx tsc --noEmit
npm run test:run -- 'src/app/(app)/collection-pool/page'
```
Expected: typecheck clean; existing page tests pass. Mock the PocketBase `update` call in tests as needed — see Task 10 for the test wiring.
- [ ] **Step 3: Commit**
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): sequential bulk-assign PATCH"
```
---
## Task 10: Page-level tests
**Files:**
- Modify: `src/app/(app)/collection-pool/page.test.tsx`
- [ ] **Step 1: Add a test that time-in-list uses `text-primary`**
Find the existing render fixture in `page.test.tsx` (search for `getPoolAgeLabel` or a row containing `text-foreground`). Append:
```tsx
import { screen, within } from "@testing-library/react";
it("renders time-in-list with text-primary class", async () => {
renderPage();
const ageLabels = await screen.findAllByText(/^\d+[hd]$/);
expect(ageLabels.length).toBeGreaterThan(0);
for (const label of ageLabels) {
expect(label.className).toMatch(/text-primary/);
}
});
```
(The selector `/^\d+[hd]$/` matches the `Nh` / `Nd` format produced by `getPoolAgeLabel`. Adjust if existing tests use a different fixture pattern — search the file for any pre-existing age assertions first.)
- [ ] **Step 2: Add a test that the Notes tooltip appears on hover**
```tsx
it("surfaces the Notes tooltip on row hover", async () => {
// Fixture row must include `notes: "Spoke to David — confirmed pickup tomorrow"`.
renderPage({ rowOverrides: { notes: "Spoke to David — confirmed pickup tomorrow" } });
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent(/Spoke to David/);
expect(tooltip).toHaveClass("hidden"); // default state
});
```
If the project's `renderPage` helper doesn't accept row overrides, add a parameter that forwards to the fixture builder — it's a one-line change.
- [ ] **Step 3: Add a test that selecting a row toggles the selection tint**
```tsx
it("applies a primary-tint background to selected rows", async () => {
const { container } = renderPage();
const firstCheckbox = await screen.findByRole("checkbox", { name: /Select .* at .*/i });
fireEvent.click(firstCheckbox);
const row = firstCheckbox.closest("li");
expect(row).not.toBeNull();
expect(row!.className).toMatch(/bg-primary\/5/);
});
```
- [ ] **Step 4: Add a test that the section "Select all" toggles every row in the section**
```tsx
it("section Select all toggles every row in the supplier group", async () => {
renderPage();
const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i });
fireEvent.click(selectAll);
const rowCheckboxes = await screen.findAllByRole("checkbox", { name: /^Select .* at/i });
expect(rowCheckboxes.length).toBeGreaterThan(0);
for (const cb of rowCheckboxes) {
expect(cb).toBeChecked();
}
});
```
- [ ] **Step 5: Add a test that the Assign-all button opens the bulk dialog with `bulkCount=N`**
Mock `pb.collection("order_items").update` so the test doesn't actually hit PocketBase. Then:
```tsx
it("opens bulk-assign dialog with bulkCount equal to selected rows", async () => {
const { container } = renderPage();
const selectAll = await screen.findByRole("checkbox", { name: /Select all \d+ stops at/i });
fireEvent.click(selectAll);
const assignAll = screen.getByRole("button", { name: /Assign \d+ stops$/ });
fireEvent.click(assignAll);
// Dialog title reads "Assign N items to driver" when bulkCount > 1.
const dialogTitle = await screen.findByText(/Assign \d+ items to driver/i);
expect(dialogTitle).toBeInTheDocument();
});
```
If the `getPocketBase()` mock in the existing suite doesn't intercept the second dialog's submit handler, extend the mock to also intercept the bulk-assign PATCH chain.
- [ ] **Step 6: Add a test that collapsing a section hides its rows**
```tsx
it("collapses a supplier section on chevron click", async () => {
renderPage();
const chevron = await screen.findByRole("button", { name: "" }); // chevron has no accessible name; query by aria-controls
const section = document.querySelector('[aria-labelledby^="supplier-"]');
const id = section!.querySelector("header")!.id;
const body = document.getElementById(`${id}-body`) || section!.querySelector('[id$="-body"]');
expect(body!.className).toMatch(/grid-rows-\[1fr\]/);
fireEvent.click(section!.querySelector("button[aria-expanded]")!);
expect(body!.className).toMatch(/grid-rows-\[0fr\]/);
});
```
- [ ] **Step 7: Run the full collection-pool suite**
```bash
npm run test:run -- 'src/app/(app)/collection-pool'
```
Expected: green. Pre-existing failures (documented across the repo, e.g. `page.test.tsx:175`) are acceptable.
- [ ] **Step 8: Commit**
```bash
git add 'src/app/(app)/collection-pool/page.test.tsx'
git commit -m "test(collection-pool): cover groups, bulk assign, notes tooltip"
```
---
## Task 11: Typecheck + size budget + conditional split
**Files:**
- Modify or create: `src/app/(app)/collection-pool/page.tsx` (or split into `components.tsx`)
- [ ] **Step 1: Full typecheck**
```bash
npx tsc --noEmit
```
Expected: clean.
- [ ] **Step 2: Measure `page.tsx` size**
```bash
wc -c 'src/app/(app)/collection-pool/page.tsx'
```
If the result is **≤ 30 KB**, stop here — the file is within budget. Skip to Step 5.
If the result is **> 30 KB**, continue with the split in Steps 3–4.
- [ ] **Step 3: Create `components.tsx` with `PoolRow` + `SupplierGroup`**
Create `src/app/(app)/collection-pool/components.tsx`:
```tsx
"use client";
import { ChevronDown, Truck, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { CollectionPoolViewRow } from "./view-model";
import {
type CollectionPoolGroup,
getAssignmentState,
getPoolAgeLabel,
} from "./view-model";
import { cn } from "@/lib/utils";
// Copy `CollectionPoolVisibleRow` type and `markerTokenToClassName`
// helper from page.tsx. They're needed by both PoolRow and SupplierGroup.
export type CollectionPoolVisibleRow = CollectionPoolViewRow & {
// mirror the type from page.tsx
};
function markerTokenToClassName(token?: string): string {
switch (token) {
case "marker-blue":
return "bg-blue-500";
case "marker-emerald":
return "bg-emerald-500";
case "marker-violet":
return "bg-violet-500";
case "marker-amber":
return "bg-amber-500";
default:
return "bg-slate-300";
}
}
// Then paste the FULL `PoolRow` and `SupplierGroup` function bodies
// from page.tsx, unchanged.
```
Cut both functions from `page.tsx` and update imports so `page.tsx` imports them from `./components` instead.
- [ ] **Step 4: Re-measure + retest**
```bash
wc -c 'src/app/(app)/collection-pool/page.tsx' 'src/app/(app)/collection-pool/components.tsx'
npx tsc --noEmit
npm run test:run -- 'src/app/(app)/collection-pool'
```
Expected: each file ≤ 30 KB; typecheck clean; tests green.
- [ ] **Step 5: Commit (split or no-op)**
If you split:
```bash
git add 'src/app/(app)/collection-pool/components.tsx' 'src/app/(app)/collection-pool/page.tsx'
git commit -m "refactor(collection-pool): split PoolRow + SupplierGroup into components.tsx"
```
If you didn't split, no commit is needed.
---
## Task 12: Manual smoke + full suite
- [ ] **Step 1: Full test suite**
```bash
npm run test:run
```
Expected: same passing set as before plus the new tests in `view-model.test.ts` and `page.test.tsx`. Pre-existing failures (the 6 we documented across the repo) are acceptable.
- [ ] **Step 2: Manual smoke on `/collection-pool`**
1. Load the page. Rows render in collapsible supplier sections, alphabetical.
2. Click the chevron on a section → body collapses with a smooth transition; chevron rotates.
3. Hover the time-in-list label — confirm it's in `text-primary` and bold.
4. Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer).
5. Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint.
6. Click `Assign N stops`. Bulk dialog opens with eyebrow `Supplier · N stops` and title `Assign N items to driver`.
7. Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear.
8. Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with `…`.
9. Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored.
- [ ] **Step 3: Commit any test-only follow-ups**
```bash
git add -A 'src/app/(app)/collection-pool/'
git commit -m "test(collection-pool): post-smoke follow-ups" --allow-empty
```
---
## Out-of-scope reminders
- **Bulk mark-ready.** The dialog requires a free-text note per item; a multi-note UI is a separate feature. Don't add it here.
- **Per-section "Mark section ready".** Same reason.
- **Route rename / nav label change.** Not in scope.
- **Mobile-only layout changes.** The existing grid already collapses correctly; no special breakpoint work is needed for the new structure.
- **Transactional batch PATCH endpoint.** Sequential single-row PATCHes are intentional; rollback on partial failure is out of scope.
---
## Risk notes
- **Page render budget.** Current 23 KB. Estimated growth ~7–10 KB from the new component + state + handlers. If it crosses 30 KB, Task 11 Step 3 splits `PoolRow` + `SupplierGroup` into `components.tsx`. If it still crosses after the split, consider further splitting the dialog wiring into a hook (`useBulkAssign`).
- **Sort change is observable.** Users who relied on the old "by urgency" default will see suppliers grouped together. The Sort radio's "Operational" label now matches this new behaviour; "Nearby" remains for cluster-priority workflows.
- **Bulk PATCH is not transactional.** A failure on row 4 of 5 leaves rows 1–3 committed. The toast reports partial success; the operator can re-open the dialog and retry.
## Provenance
Formalized by Open Design from candidate 5cb8fc93-2209-4312-ad86-e1d93493fe2d.