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.
1258 lines
43 KiB
Markdown
1258 lines
43 KiB
Markdown
# 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.
|