WFDASH-WEBAPP/docs/superpowers/plans/2026-07-09-collection-pool-mark-ready-notes.md

742 lines
No EOL
23 KiB
Markdown

# Collection Pool Mark-Ready Notes — 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:** Require a free-text note when an operator marks a collection-pool row ready. The note is stored in the existing `OrderItem.notes` field and surfaces as a small chip on the row.
**Architecture:** New modal on the Collection Pool page; extend the existing PATCH route's body schema to accept `ready_notes`; add a notes chip on each row.
**Tech Stack:** Next.js 16 client component, shadcn/ui (Dialog + Textarea + Button + Tooltip), Vitest, PocketBase admin.
**Spec:** [docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md](file:///Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md)
---
## File Structure
| File | Responsibility | Touched? |
| --- | --- | --- |
| `src/app/api/v1/collection-map/items/[id]/assign/route.ts` | Accept `ready_notes`, validate required-on-ready, write to `notes` | Modify |
| `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` | Cover new validation + write paths | Create |
| `src/components/ui/mark-ready-dialog.tsx` | New controlled modal | Create |
| `src/components/ui/mark-ready-dialog.test.tsx` | New component tests | Create |
| `src/app/(app)/collection-pool/page.tsx` | Open modal from `Mark Ready`, render notes chip | Modify |
---
## Task 1: Server — accept `ready_notes` and write to `notes`
**Files:**
- Modify: `src/app/api/v1/collection-map/items/[id]/assign/route.ts`
- [ ] **Step 1: Extend the body type**
In the route file, update the `BodyInput` type:
```ts
type BodyInput = {
driverId?: string;
date?: string;
ready_for_collection?: boolean;
ready_notes?: string;
};
```
- [ ] **Step 2: Validate `ready_notes` is required when ready**
Right after the `body = ...` parse, add the validation block:
```ts
// `ready_notes` is required (non-empty after trim) when the operator
// is marking a row ready. The Collection Pool modal enforces this
// client-side, but we re-check here so a direct API call can't skip
// it. `ready_notes` is ignored (and not required) on the unmark
// path and on pure driver-assign paths.
const readyNotes = typeof body.ready_notes === "string" ? body.ready_notes.trim() : "";
const isMarkingReady = body.ready_for_collection === true;
if (isMarkingReady && readyNotes.length === 0) {
return NextResponse.json(
{ error: "A note is required when marking ready." },
{ status: 400 },
);
}
```
Place this AFTER the existing `assignedAt` validation and BEFORE the `isAssigning` block.
- [ ] **Step 3: Add `notes: readyNotes` to the patch when marking ready**
Inside the `if (typeof body.ready_for_collection === "boolean")` block (currently sets `patch.ready_for_collection`), also set the `notes` field when ready is true. The whole block becomes:
```ts
if (typeof body.ready_for_collection === "boolean") {
patch.ready_for_collection = body.ready_for_collection;
if (body.ready_for_collection && readyNotes.length > 0) {
// Trimmed server-side so the audit log is always clean.
patch.notes = readyNotes;
}
}
```
- [ ] **Step 4: Surface the saved note in the response**
The `applied` object is informational; add `notes: patch.notes ?? null` so the client can confirm what was written:
```ts
applied: {
item_status: patch.item_status,
drv_status: patch.drv_status,
drv_id: patch.drv_id,
assigned_at: patch.assigned_at,
notes: patch.notes ?? null,
ready_for_collection: patch.ready_for_collection,
},
```
- [ ] **Step 5: Lint**
```bash
npx eslint 'src/app/api/v1/collection-map/items/[id]/assign/route.ts'
```
Expected: exit 0. Pre-existing errors (if any) on unrelated lines are fine.
- [ ] **Step 6: Commit**
```bash
git add 'src/app/api/v1/collection-map/items/[id]/assign/route.ts'
git commit -m "feat(collection-map): require ready_notes when marking ready"
```
---
## Task 2: Failing tests for the route
**Files:**
- Create: `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts`
- [ ] **Step 1: Write the failing tests**
The tests need the same admin-PB mock pattern as the other v1 route tests. Mock `getAdminPocketBase` to return a fake client with `collection(name).getOne` and `update` on it. Use the production admin pattern: `collection(name).getOne(id).catch(() => null)` for the existence check, then `update(id, patch)`.
```ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const getOneMock = vi.fn();
const updateMock = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({
getOne: getOneMock,
update: updateMock,
}),
}),
}));
import { PATCH } from "./route";
function makeReq(body: unknown) {
return new NextRequest(
"http://localhost/api/v1/collection-map/items/item-1/assign",
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
describe("PATCH /api/v1/collection-map/items/[id]/assign", () => {
beforeEach(() => {
getOneMock.mockReset();
updateMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it("writes trimmed notes to the item when ready_notes is provided", async () => {
getOneMock.mockResolvedValueOnce({ id: "item-1" });
updateMock.mockResolvedValueOnce({ id: "item-1" });
const res = await PATCH(
makeReq({
ready_for_collection: true,
ready_notes: " spoke to David, ETA tomorrow ",
}),
{ params: Promise.resolve({ id: "item-1" }) },
);
expect(res.status).toBe(200);
const payload = updateMock.mock.calls[0][1] as Record<string, unknown>;
expect(payload.notes).toBe("spoke to David, ETA tomorrow");
expect(payload.ready_for_collection).toBe(true);
});
it("returns 400 when ready_for_collection is true and ready_notes is empty", async () => {
const res = await PATCH(
makeReq({ ready_for_collection: true, ready_notes: "" }),
{ params: Promise.resolve({ id: "item-1" }) },
);
expect(res.status).toBe(400);
expect(updateMock).not.toHaveBeenCalled();
});
it("returns 400 when ready_for_collection is true and ready_notes is whitespace only", async () => {
const res = await PATCH(
makeReq({ ready_for_collection: true, ready_notes: " \n\t " }),
{ params: Promise.resolve({ id: "item-1" }) },
);
expect(res.status).toBe(400);
expect(updateMock).not.toHaveBeenCalled();
});
it("returns 400 when ready_for_collection is true and ready_notes is missing", async () => {
const res = await PATCH(
makeReq({ ready_for_collection: true }),
{ params: Promise.resolve({ id: "item-1" }) },
);
expect(res.status).toBe(400);
expect(updateMock).not.toHaveBeenCalled();
});
it("accepts the unmark path without a note (ready_for_collection: false)", async () => {
getOneMock.mockResolvedValueOnce({ id: "item-1", notes: "existing" });
updateMock.mockResolvedValueOnce({ id: "item-1" });
const res = await PATCH(
makeReq({ ready_for_collection: false }),
{ params: Promise.resolve({ id: "item-1" }) },
);
expect(res.status).toBe(200);
const payload = updateMock.mock.calls[0][1] as Record<string, unknown>;
expect(payload.ready_for_collection).toBe(false);
// The unmark path must not silently overwrite the existing note.
expect(payload.notes).toBeUndefined();
});
});
```
- [ ] **Step 2: Run the test file**
```bash
npm run test:run -- 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
```
Expected: 5/5 pass (the implementation from Task 1 should make all of them green).
- [ ] **Step 3: Lint**
```bash
npx eslint 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
```
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
git commit -m "test(collection-map): cover ready_notes required-on-ready"
```
---
## Task 3: New `MarkReadyDialog` component
**Files:**
- Create: `src/components/ui/mark-ready-dialog.tsx`
- [ ] **Step 1: Create the component**
```tsx
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
/**
* Modal that asks the operator for a free-text note before marking a
* collection-pool row ready. The note is required (non-empty after
* trim) and is saved into `OrderItem.notes` by the server route. We
* also surface a small "Notes" chip on the row whenever the row has
* any notes, so the operator can see at a glance whether they've
* already written something.
*
* The submit button is disabled until the textarea is non-empty.
* Whitespace-only content is treated as empty for the disabled
* state and the server validation.
*/
export interface MarkReadyDialogProps {
open: boolean;
/** PO number + item description, formatted for the title. */
poLabel: string;
/** Item description, formatted for the title. */
itemLabel: string;
/** Any pre-existing note (not currently used; reserved for edit flows). */
initialNote?: string;
busy?: boolean;
serverError?: string | null;
onOpenChange: (open: boolean) => void;
onSubmit: (note: string) => void;
}
export function MarkReadyDialog({
open,
poLabel,
itemLabel,
initialNote = "",
busy = false,
serverError = null,
onOpenChange,
onSubmit,
}: MarkReadyDialogProps) {
const [note, setNote] = useState(initialNote);
const trimmed = note.trim();
const canSubmit = trimmed.length > 0 && !busy;
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!busy) {
if (!o) setNote("");
onOpenChange(o);
}
}}
>
<DialogContent className="sm:max-w-md max-h-[90vh] grid-rows-[auto_1fr_auto] gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle>
Mark ready: {poLabel} · {itemLabel}
</DialogTitle>
<DialogDescription>
Add a note for the audit log. Required.
</DialogDescription>
</DialogHeader>
<div className="px-6 py-4 space-y-2 overflow-y-auto">
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Spoke to David — confirmed pickup tomorrow 09:00"
rows={4}
maxLength={500}
autoFocus
disabled={busy}
className="min-h-24"
onKeyDown={(e) => {
// Cmd/Ctrl+Enter submits, like a comment box.
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canSubmit) {
e.preventDefault();
onSubmit(trimmed);
}
}}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{note.length}/500</span>
<span className="hidden sm:inline">⌘/Ctrl + Enter to save</span>
</div>
{serverError && (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
>
{serverError}
</p>
)}
</div>
<DialogFooter className="px-6 py-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => onSubmit(trimmed)}
disabled={!canSubmit}
>
{busy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
Save &amp; Mark Ready
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
Note: this component imports the existing `Dialog` / `DialogContent` / etc. from `@/components/ui/dialog`. It also imports `Textarea` and `Button`. If any of those are missing, add them.
- [ ] **Step 2: Lint**
```bash
npx eslint src/components/ui/mark-ready-dialog.tsx
```
Expected: exit 0.
- [ ] **Step 3: Commit (component only)**
```bash
git add src/components/ui/mark-ready-dialog.tsx
git commit -m "feat(collection-pool): add MarkReadyDialog component"
```
---
## Task 4: Failing tests for `MarkReadyDialog`
**Files:**
- Create: `src/components/ui/mark-ready-dialog.test.tsx`
- [ ] **Step 1: Write the failing tests**
```tsx
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { MarkReadyDialog } from "./mark-ready-dialog";
const PO_LABEL = "PO-2026-07-009";
const ITEM_LABEL = "Magnetic drill";
describe("MarkReadyDialog", () => {
beforeEach(() => {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
window.HTMLElement.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
cleanup();
});
it("renders the PO and item in the title", () => {
render(
<MarkReadyDialog
open
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
poLabel={PO_LABEL}
itemLabel={ITEM_LABEL}
/>,
);
expect(
screen.getByText(`Mark ready: ${PO_LABEL} · ${ITEM_LABEL}`),
).toBeInTheDocument();
});
it("disables Save when the textarea is empty", () => {
render(
<MarkReadyDialog
open
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
poLabel={PO_LABEL}
itemLabel={ITEM_LABEL}
/>,
);
expect(screen.getByRole("button", { name: /save & mark ready/i })).toBeDisabled();
});
it("disables Save when the textarea is whitespace only", () => {
render(
<MarkReadyDialog
open
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
poLabel={PO_LABEL}
itemLabel={ITEM_LABEL}
/>,
);
fireEvent.change(screen.getByRole("textbox"), {
target: { value: " \n " },
});
expect(screen.getByRole("button", { name: /save & mark ready/i })).toBeDisabled();
});
it("enables Save and submits the trimmed note", () => {
const onSubmit = vi.fn();
render(
<MarkReadyDialog
open
onOpenChange={vi.fn()}
onSubmit={onSubmit}
poLabel={PO_LABEL}
itemLabel={ITEM_LABEL}
/>,
);
fireEvent.change(screen.getByRole("textbox"), {
target: { value: " spoke to David " },
});
const save = screen.getByRole("button", { name: /save & mark ready/i });
expect(save).not.toBeDisabled();
fireEvent.click(save);
expect(onSubmit).toHaveBeenCalledWith("spoke to David");
});
it("renders a server error without closing the modal", () => {
render(
<MarkReadyDialog
open
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
poLabel={PO_LABEL}
itemLabel={ITEM_LABEL}
serverError="A note is required when marking ready."
/>,
);
expect(
screen.getByRole("alert", { name: /a note is required when marking ready/i }) ??
screen.getByText(/a note is required when marking ready/i),
).toBeInTheDocument();
});
it("does not call onSubmit when the dialog is closed", () => {
const onSubmit = vi.fn();
const onOpenChange = vi.fn();
render(
<MarkReadyDialog
open={false}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
poLabel={PO_LABEL}
itemLabel={ITEM_LABEL}
/>,
);
fireEvent.click(screen.queryByRole("button", { name: /save & mark ready/i })!);
expect(onSubmit).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run the test file**
```bash
npm run test:run -- src/components/ui/mark-ready-dialog.test.tsx
```
Expected: 6/6 pass.
- [ ] **Step 3: Lint**
```bash
npx eslint src/components/ui/mark-ready-dialog.test.tsx
```
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/components/ui/mark-ready-dialog.test.tsx
git commit -m "test(collection-pool): cover MarkReadyDialog behaviour"
```
---
## Task 5: Wire the modal into the Collection Pool page
**Files:**
- Modify: `src/app/(app)/collection-pool/page.tsx`
- [ ] **Step 1: Add the new state and import**
Near the top of the page component, alongside `assignDialog`, add the new state for the mark-ready modal:
```ts
const [readyDialogRow, setReadyDialogRow] = useState<CollectionPoolVisibleRow | null>(null);
const [readyDialogError, setReadyDialogError] = useState<string | null>(null);
```
Also import the new dialog component near the top of the file (alongside the existing `AssignDriverDialog` import):
```ts
import { MarkReadyDialog } from "@/components/ui/mark-ready-dialog";
```
- [ ] **Step 2: Replace `handleToggleReady` with the modal-opening handler**
The current `handleToggleReady` calls the route directly. Replace it with two handlers — one that opens the modal when marking ready, one that just toggles the flag when unmarking. Also add a `submitMarkReady` handler.
Find the existing `handleToggleReady` callback (search for `handleToggleReady`) and replace it with:
```ts
const handleToggleReady = useCallback(
async (row: CollectionPoolVisibleRow, ready: boolean) => {
// Unmarking is a single PATCH with no note. The modal only opens
// for the "mark ready" direction.
if (!ready) {
setBusyId(row.id);
try {
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ready_for_collection: false }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
toast.success("Marked awaiting supplier confirmation.");
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusyId(null);
}
return;
}
// Marking ready opens the modal. The actual PATCH is fired from
// `submitMarkReady` once the operator has typed a note.
setReadyDialogError(null);
setReadyDialogRow(row);
},
[load],
);
const submitMarkReady = useCallback(
async (note: string) => {
const row = readyDialogRow;
if (!row) return;
setBusyId(row.id);
setReadyDialogError(null);
try {
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ready_for_collection: true,
ready_notes: note,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = body?.error || `HTTP ${res.status}`;
// Keep the modal open so the operator can fix the error.
setReadyDialogError(message);
throw new Error(message);
}
toast.success("Marked ready — note saved.");
setReadyDialogRow(null);
await load();
} catch (err) {
// Error already shown via readyDialogError / toast.
console.error("Mark ready failed:", err);
} finally {
setBusyId(null);
}
},
[load, readyDialogRow],
);
```
- [ ] **Step 3: Update the existing row button to open the modal**
Find the `Mark Ready` / `Mark Awaiting` button in the row JSX (search for `Mark Ready`). The `onClick` currently calls `() => onToggleReady(row.id, !row.ready_for_collection)`. Change it to call the new handler with the row object instead of just the id:
```tsx
onClick={() => handleToggleReady(row, !row.ready_for_collection)}
```
You will need to confirm `row` here is a `CollectionPoolVisibleRow` (not the raw `CollectionPoolRow`). Check the surrounding `row` variable. If it's the raw row, look at the parent loop to see if there's a visible-row type you can use. If only the raw row is in scope, adapt the handler to take the raw row (the `useCallback` deps will need `row.id` etc.).
- [ ] **Step 4: Render the dialog and the notes chip**
Near the bottom of the component, just before the closing `</div>` of the page root (search for the existing `AssignDriverDialog` JSX to find the spot), add:
```tsx
{readyDialogRow && (
<MarkReadyDialog
open
onOpenChange={(o) => {
if (!o) {
setReadyDialogRow(null);
setReadyDialogError(null);
}
}}
onSubmit={submitMarkReady}
poLabel={readyDialogRow.poNumber}
itemLabel={readyDialogRow.description}
busy={busyId === readyDialogRow.id}
serverError={readyDialogError}
/>
)}
```
For the notes chip, find the row's existing badge area (search for the `Ready`/`Awaiting` badge — the `readyLabel` constant). Add a notes chip next to it. The chip is `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` and shows `title={row.notes}` (browser tooltip) + `Notes` text. Render only if `row.notes && row.notes.trim().length > 0`. If you're uncertain about the row type having `notes`, check the `CollectionPoolRow` type at the top of the file (it already has `notes: string` from the base `OrderItem` type — confirmed by the audit earlier).
- [ ] **Step 5: Lint**
```bash
npx eslint 'src/app/(app)/collection-pool/page.tsx'
```
Expected: exit 0. The pre-existing 6 test failures in this file are not lint errors — they're the ones we already documented as a pre-existing issue. Lint should pass cleanly.
- [ ] **Step 6: Run any existing collection-pool tests**
```bash
npm run test:run -- 'src/app/(app)/collection-pool'
```
Expected: same 6 pre-existing failures. No new failures.
- [ ] **Step 7: Commit**
```bash
git add 'src/app/(app)/collection-pool/page.tsx'
git commit -m "feat(collection-pool): require note on Mark Ready"
```
---
## Task 6: Final verification
- [ ] **Step 1: Targeted tests**
```bash
npm run test:run -- 'src/app/api/v1/collection-map/items/[id]/assign' src/components/ui/mark-ready-dialog
```
Expected: all targeted tests pass (5 route + 6 component = 11).
- [ ] **Step 2: Full suite**
```bash
npm run test:run
```
Expected: same passing set as before plus the new tests. The 6 pre-existing `collection-pool` failures remain (they're a separate issue).
- [ ] **Step 3: Manual smoke**
1. Open the Collection Pool page.
2. Find any row, click `Mark Ready`. The modal should open.
3. Try clicking `Save & Mark Ready` with an empty textarea — button is disabled.
4. Type a note, click save. The modal should close, the row should show a `NOTES` chip, and a toast should confirm.
5. On the same row, click `Mark Awaiting`. No modal, just a toast.
6. Repeat the `Mark Ready` path with a network error (e.g. pause PB) — the modal should stay open with the error visible.