From 4e0d45198b747d9caab3333d2ae8d74b8b2adc33 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Fri, 10 Jul 2026 00:02:43 +0200 Subject: [PATCH] Add collection pool mark-ready notes implementation plan --- ...-07-09-collection-pool-mark-ready-notes.md | 742 ++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-collection-pool-mark-ready-notes.md diff --git a/docs/superpowers/plans/2026-07-09-collection-pool-mark-ready-notes.md b/docs/superpowers/plans/2026-07-09-collection-pool-mark-ready-notes.md new file mode 100644 index 0000000..463f4cb --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-collection-pool-mark-ready-notes.md @@ -0,0 +1,742 @@ +# 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; + 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; + 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 ( + { + if (!busy) { + if (!o) setNote(""); + onOpenChange(o); + } + }} + > + + + + Mark ready: {poLabel} · {itemLabel} + + + Add a note for the audit log. Required. + + +
+