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

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_notes is 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: 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:

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 &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
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 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:

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
  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.