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.
43 KiB
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
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" perAGENTS.md). - Re-read
page.tsx(663 lines) —PoolRowprops, the colour-stripe helper, the existing single-rowAssignDriverDialogmount, and theload()+updateItemStatuspatterns.
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):
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
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:
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
npm run test:run -- 'src/app/(app)/collection-pool/view-model.test'
Expected: all tests green.
- Step 5: Commit
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:
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
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
buildVisibleRowssort
In src/app/(app)/collection-pool/view-model.ts, replace the existing .sort((a, b) => { ... }) callback inside buildVisibleRows with:
.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
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:
// 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
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
PoolRoworder-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):
<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
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
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
titlechip with agroup/notestooltip
In src/app/(app)/collection-pool/page.tsx, replace the existing notes chip block:
{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
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
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
selectedIdsstate + handlers
In CollectionPoolPage, alongside the existing useState calls (around line 106–116), add:
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
PoolRowprops and signature
Update the PoolRow props type and signature in the same file (around line 551):
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:
<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:
<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
npm run test:run -- 'src/app/(app)/collection-pool/page'
Expected: existing tests still pass (PoolRow is exercised via the page).
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(newSupplierGroupfunction) -
Step 1: Add the
SupplierGroupcomponent
Append a new SupplierGroup function inside page.tsx, after PoolRow. It renders the section header strip and the collapsible row body:
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:
import { AlertTriangle, ChevronDown, Loader2, Search, Truck } from "lucide-react";
And update the ./view-model imports so buildSupplierGroups, CollectionPoolGroup, and getAssignmentBadge are all present:
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
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)
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
collapsedSuppliersstate
In CollectionPoolPage, next to selectedIds:
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
openBulkAssignhandler 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.)
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
groupsin the page
Below visibleRows, add:
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:
<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
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
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(secondAssignDriverDialogmount) -
Step 1: Stub the submit handler + bulkBusy state
Just below handleAssignDialogSubmit, add:
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:
<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
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(handleBulkAssignSubmitbody) -
Step 1: Replace the stub with the real implementation
In page.tsx, replace the empty handleBulkAssignSubmit with:
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
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
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:
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
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
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
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:
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
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
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
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 intocomponents.tsx) -
Step 1: Full typecheck
npx tsc --noEmit
Expected: clean.
- Step 2: Measure
page.tsxsize
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.tsxwithPoolRow+SupplierGroup
Create src/app/(app)/collection-pool/components.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
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:
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
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
- Load the page. Rows render in collapsible supplier sections, alphabetical.
- Click the chevron on a section → body collapses with a smooth transition; chevron rotates.
- Hover the time-in-list label — confirm it's in
text-primaryand bold. - Hover the Notes chip on a row with notes — confirm the tooltip appears with the note text (truncated at 280 chars + ellipsis if longer).
- Tick the section "Select all" — every row checkbox in the section ticks; row backgrounds tint.
- Click
Assign N stops. Bulk dialog opens with eyebrowSupplier · N stopsand titleAssign N items to driver. - Pick a driver + tomorrow's date, submit. Toast confirms. Reload — rows show "Assigned future" badge and the section's selections clear.
- Mark a row Ready with a long note (>280 chars). Hover the Notes chip — the preview is truncated with
…. - Switch sort to "Nearby suppliers" — order changes; switch back to "Operational" — supplier-grouped order restored.
- Step 3: Commit any test-only follow-ups
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+SupplierGroupintocomponents.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.