23 KiB
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 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:
type BodyInput = {
driverId?: string;
date?: string;
ready_for_collection?: boolean;
ready_notes?: string;
};
- Step 2: Validate
ready_notesis required when ready
Right after the body = ... parse, add the validation block:
// `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: readyNotesto 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:
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:
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
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
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).
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
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
npx eslint 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
Expected: exit 0.
- Step 4: Commit
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
"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 & 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
npx eslint src/components/ui/mark-ready-dialog.tsx
Expected: exit 0.
- Step 3: Commit (component only)
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
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
npm run test:run -- src/components/ui/mark-ready-dialog.test.tsx
Expected: 6/6 pass.
- Step 3: Lint
npx eslint src/components/ui/mark-ready-dialog.test.tsx
Expected: exit 0.
- Step 4: Commit
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:
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):
import { MarkReadyDialog } from "@/components/ui/mark-ready-dialog";
- Step 2: Replace
handleToggleReadywith 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:
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:
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:
{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
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
npm run test:run -- 'src/app/(app)/collection-pool'
Expected: same 6 pre-existing failures. No new failures.
- Step 7: Commit
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
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
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
- Open the Collection Pool page.
- Find any row, click
Mark Ready. The modal should open. - Try clicking
Save & Mark Readywith an empty textarea — button is disabled. - Type a note, click save. The modal should close, the row should show a
NOTESchip, and a toast should confirm. - On the same row, click
Mark Awaiting. No modal, just a toast. - Repeat the
Mark Readypath with a network error (e.g. pause PB) — the modal should stay open with the error visible.