diff --git a/docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md b/docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md new file mode 100644 index 0000000..7f16875 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md @@ -0,0 +1,144 @@ +# Collection Pool — required notes on Mark Ready + +**Goal:** When an operator clicks `Mark Ready` on a collection-pool row, require a free-text note (saved into `OrderItem.notes`) before the request is sent. The note captures context like "spoke to David", "ETA tomorrow", "no stock — partial". + +## Scope + +### In Scope + +- New modal on the Collection Pool page that opens when the operator clicks `Mark Ready`. +- Modal has a required `Textarea` (with a character counter) and a primary `Save & Mark Ready` button. +- A `Cancel` button closes the modal without persisting. +- On submit: the modal calls the existing `PATCH /api/v1/collection-map/items/{id}/assign` with `{ ready_for_collection: true, ready_notes: "..." }`. The route extends its body schema to accept and persist the new field. +- Server route writes `ready_notes` into `OrderItem.notes` (free text). It does NOT create a new PB field. +- A small `Notes` chip appears on the row whenever the row has any `notes` content, with a tooltip showing the first ~120 characters. +- Unmarking (`Mark Awaiting`) does NOT require a note — the existing flow is unchanged. + +### Out of Scope + +- Creating a new PB field for "ready notes". The existing `OrderItem.notes` text field is the right place. +- Changes to other collection-pool actions (assign driver, reschedule, mark complete). +- Migrating old `OrderItem.notes` values into the new modal prefill. + +## Current State + +- `Mark Ready` in the Collection Pool page calls `PATCH /api/v1/collection-map/items/{id}/assign` with `{ ready_for_collection: true }`. No notes. +- The route in `src/app/api/v1/collection-map/items/[id]/assign/route.ts` already accepts a body shape; the body type is `{ driverId?, date?, ready_for_collection? }`. We extend the type with `ready_notes?`. +- The existing `OrderItem.notes` field is a free-text string on the order item. It's already rendered in the Order Detail page. + +## User Confirmed Rules + +- The note is required when the operator clicks `Mark Ready`. +- Unmarking (`Mark Awaiting`) does not require a note. +- A simple textarea is enough — no structured fields. +- A small `Notes` chip on the row shows when notes exist (tooltip with the first 120 chars). + +## UX + +### Modal + +``` +┌─ Mark ready: · ──────────────────────┐ +│ Add a note for the audit log. Required. │ +│ │ +│ [ multi-line textarea, rows=4, maxLength=500 ] │ +│ │ +│ 0/500 │ +│ │ +│ [ Cancel ] [ Save & Mark Ready ] │ +└───────────────────────────────────────────────────────┘ +``` + +- `Save & Mark Ready` is disabled until the textarea is non-empty. +- `autoFocus` on the textarea. +- Same wide-modal treatment as the Send PO modal: `sm:max-w-md` is enough (single-column, ~28rem); `max-h-[90vh]` so the body scrolls on tiny screens. +- Title: `Mark ready: {PO Number} · {item description}` (truncated). +- Description: short hint about why a note is required. +- On success: close the modal, refresh the row, show toast `Marked ready — note saved.` +- On failure: keep the modal open, show the error in the footer area, allow retry. + +### Row chip + +- A small uppercase chip `NOTES` on the row (next to the existing `ready`/`awaiting` badge) when `row.notes?.trim()` is non-empty. +- Hover/focus shows a tooltip with the first 120 chars + `…` if longer. +- Empty notes: no chip. +- Click on chip: no action (read-only audit indicator). + +## Data Model + +No schema change. We reuse the existing `OrderItem.notes` field on the order item record. Operators already see this field on the Order Detail page, so the note is visible everywhere the row shows up. + +The modal writes the value via the existing PATCH route. The route's body schema gets one new optional field: + +```ts +type BodyInput = { + driverId?: string; + date?: string; + ready_for_collection?: boolean; + ready_notes?: string; // NEW +}; +``` + +`ready_notes` is required (in spirit) when `ready_for_collection === true` and the request is NOT also assigning a driver (the existing `AssignDriverDialog` flow doesn't take notes). The route enforces the requirement: + +- If `ready_for_collection === true` AND `ready_notes` is empty/missing → 400 with `{ error: "A note is required when marking ready." }`. + +## Behavior + +### Operator flow + +1. Operator clicks `Mark Ready` on a row. +2. Modal opens with an empty textarea (and `Save & Mark Ready` disabled). +3. Operator types a note; the button enables. +4. Operator clicks `Save & Mark Ready`. The button shows a spinner while the request is in flight. +5. On 2xx, the modal closes, the row refreshes (it now shows the new note in the chip), and a toast confirms. +6. On 4xx/5xx, the modal stays open with the error in the footer. + +### Server flow + +- PATCH `/api/v1/collection-map/items/{id}/assign` with `{ ready_for_collection: true, ready_notes: "..." }`. +- Route validates: if `ready_for_collection` is true and `ready_notes` is empty/whitespace, return 400. +- Otherwise, write the patch (existing patch logic) AND `notes: ready_notes.trim()` to the order item. +- Return the updated item. + +### Audit trail + +The note becomes part of the order item record. It's visible on: +- The Collection Pool row chip. +- The Order Detail page's order item view. +- Anywhere else the order item renders. + +## Testing + +### UI tests (collection-pool/page.test.tsx) + +- Clicking `Mark Ready` opens the modal. +- The modal's primary button is disabled when the textarea is empty. +- The modal's primary button is enabled once the textarea has any non-whitespace content. +- Submitting the modal calls the PATCH endpoint with `{ ready_for_collection: true, ready_notes: "" }`. +- Submitting a note that is only whitespace (e.g. " ") is treated as empty. +- The modal closes on a 2xx response. +- The modal stays open on a 4xx/5xx response and shows the error. +- A 400 from the server for missing note is rendered to the user, not just a toast. + +### Server tests (route.test.ts) + +- PATCH with `{ ready_for_collection: true, ready_notes: "spoke to David" }` writes `notes: "spoke to David"` to the item. +- PATCH with `{ ready_for_collection: true, ready_notes: "" }` returns 400. +- PATCH with `{ ready_for_collection: true, ready_notes: " " }` returns 400 (whitespace treated as empty). +- PATCH with `{ ready_for_collection: false, ready_notes: "..." }` returns 200 and does NOT write notes (only the ready flag is updated; notes is left as-is). This is the unmarking path; no note required. +- PATCH with `{ ready_for_collection: true }` and no `ready_notes` returns 400. +- The note is trimmed before being written (no leading/trailing whitespace). + +## Acceptance Criteria + +- Marking a row Ready without a note is impossible — both the UI and the server block it. +- The note is persisted in `OrderItem.notes` and visible on the row chip and the Order Detail page. +- Unmarking (`Mark Awaiting`) does not require a note. +- The PATCH route is backward-compatible — old callers that send `{ ready_for_collection: true }` without `ready_notes` now get a 400, which is the intended new behavior. + +## Risk / Rollback + +- Low risk. No schema change, no new PB field. +- The only compatibility break is the now-required `ready_notes` on the server. The current client always sends nothing in this slot, so existing UI flows that don't have a note will start receiving 400s. The modal change ships the same commit, so they go green together. +- Rollback: revert the modal and the route validation. The `notes` write stays as a no-op write. \ No newline at end of file