diff --git a/docs/superpowers/specs/2026-08-27-approve-and-send-from-list-design.md b/docs/superpowers/specs/2026-08-27-approve-and-send-from-list-design.md new file mode 100644 index 0000000..2e9a61d --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-approve-and-send-from-list-design.md @@ -0,0 +1,152 @@ +# Approve & Send from Pending-Approvals List — design spec + +**Status:** Approved (verbal, 2026-08-27; awaiting written-spec review) +**Date:** 2026-08-27 +**Owner:** Dave + +--- + +## Summary + +The Pending Approvals list page (`/quotes/approvals`) currently exposes "Approve" and "Approve partial" buttons per row that **stop short of sending** — they only flip the status to `approved` and tell the operator via toast to drill in to send. The detail-screen already has a one-step "Approve & send email" button that goes straight through `review-approve`. Lift that detail-screen behaviour onto the list rows. Operator clicks the row button once, the same `SendQuoteModal` opens, they confirm, and the email goes. + +This trims one round-trip for the common case where the rep already trusts the quote. + +--- + +## Motivation + +Today, on the approvals list, the operator must: +1. Click "Approve" on the row → row leaves the list with status `approved`, toast says "proceed to send from the quote page" +2. Drill into the quote detail (now under `/quotes/[id]` or the orders page) and send separately + +Most of the time the rep is ready to approve *and* send at the same moment. The detail page already supports this via "Approve & send email". The list should match. + +--- + +## Decisions log + +Resolved during brainstorming on 2026-08-27: + +| Question | Decision | +|---|---| +| Which detail-screen button gets lifted? | The "Approve & send email" button — the one that opens `SendQuoteModal` and submits via `review-approve action: "approve"`. | +| Replace or supplement the existing list button? | Replace. "We never just approve it" — the standalone approve path on the list is no longer useful once the combined path exists. | +| Same treatment for partial rows? | Yes. The current "Approve partial" button also gets the combined treatment. `review-approve` already handles `partial_approval_pending` (flips to `partial_approved → sending → emailed`). The narrower `/partial-approve` endpoint is no longer called from the list. | +| Modal opens over list or replaces list? | Opens over the list (same modal component, no UI to clear it). Same affordance as today on the detail page. | +| Inline markup-edit affordance from list row? | Out of scope for v1. If the rep needs to nudge markup, they drill in (existing escape hatch preserved). | +| "Approve without send" toggle? | Deferred. Reuses the future `SendQuoteModal` toggle mentioned in conversation; not in this commit. | + +--- + +## UI + +### Files touched + +- `src/app/(app)/quotes/approvals/page.tsx` — only file. + +### Per-row button changes + +| Row state | Current button | New button | +|---|---|---| +| `approval_pending` | "Approve" → `pb.collection.update` direct write | "Approve & send" → opens `SendQuoteModal`, then calls `review-approve action: "approve"` | +| `partial_approval_pending` | "Approve partial" → POST `/partial-approve` | "Approve & send" → opens `SendQuoteModal`, then calls `review-approve action: "approve"` (server handles partials) | +| Both | "Reject" → existing handler | "Reject" (unchanged) | + +Button style matches the detail page's existing "Approve & send email" button — same label, same visual treatment (secondary button group typically). The detail page already passes `submitLabel="Approve & send"` to `SendQuoteModal`; the list passes the same. + +### `SendQuoteModal` integration + +The modal is reused unchanged. The list page needs: +- A piece of state per row: `openSendModalForRowId: string | null` (or a Map keyed by row id) +- A single `sendApprove` callback lifted to page level (same shape as the detail page's `sendApprove`), callable per row +- On success: refresh the list (`router.refresh()` or local state update), toast same as detail page + +### Toast behaviour + +Same as detail page — branched: +- Full approve success → `"Approved — email sent to the customer."` +- Partial approve success → `"Partial approved — email sent for the priced items."` +- Mail send failure (422) → `"Saved — but email send failed: . Retry from the quote page."` +- Race / already-approved (422 invalid_status) → parse `{error}` body, surface `"This quote was already actioned — refreshing."` (then refresh the list) + +The existing toasts `"Quote approved — proceed to send from the quote page"` and `" items marked approved — open the quote to send the email."` are removed; they describe the old broken two-step flow. + +--- + +## API surface + +### Reused (no new endpoints) + +- `POST /api/v1/quotes/[id]/review-approve` with `action: "approve"`, optional `email{}`, optional `itemChanges[]` — already atomically approves and sends. Already handles full + partial quotes. Already rejects on `placeholder_customer`, `invalid_status`, mail-fail. The detail page calls this; the list page will too. + +### No longer called from the list + +- `POST /api/v1/quotes/[id]/partial-approve` — the list's "Approve partial" button used this; replaced by `review-approve`. The endpoint stays (used elsewhere in the app). +- Direct `pb.collection("quotes").update(...)` writes from the list — replaced by `review-approve`. + +--- + +## Error handling + +| Failure | Behaviour | +|---|---| +| `placeholder_customer` (422) | Modal disables the submit button on render (matches detail's `customerIsPlaceholder` gate). Operator drills in to fix. | +| `invalid_status` (422) — already approved by another rep, race | Surface toast: `"This quote was already actioned — refreshing."` then `router.refresh()`. | +| Mail send failure (422 — email sub-object) | Toast: `"Saved — but email send failed: . Retry from the quote page."` Row stays in list with `mail_failed` status (server flips it). | +| Network/5xx | Toast: `"Couldn't approve. Try again."` Keep modal open. | +| `customer` not found / record deleted (404) | Toast: `"This quote no longer exists. Refreshing."` then `router.refresh()`. | + +--- + +## Tests + +Component-level tests for the list page (`src/app/(app)/quotes/approvals/page.test.tsx` if it exists; if not, create a focused spec file that does NOT replicate the entire list page render — only exercises the button + modal interaction). + +| # | Test | +|---|---| +| 1 | Clicking "Approve & send" on an `approval_pending` row opens `SendQuoteModal` with the quote pre-loaded | +| 2 | Submitting the modal calls `POST /api/v1/quotes/[id]/review-approve` with `action: "approve"` and an `email{}` payload taken from the modal | +| 3 | On 200, the list removes the row (filter no longer matches) and shows the success toast | +| 4 | On 422 `invalid_status`, the list refreshes and shows a toast | +| 5 | On 422 mail-failed, the toast reads "Saved — but email send failed..." | +| 6 | Clicking "Approve & send" on a `partial_approval_pending` row invokes the same endpoint and shows the partial-success toast | + +`SendQuoteModal` itself is not re-tested — it's the existing component, unchanged. + +`review-approve` endpoint tests already cover the success / placeholder / mail-fail branches in `src/app/api/v1/quotes/[id]/review-approve/route.test.ts` — re-run those after the merge to confirm no regression. + +--- + +## Out of scope (deferred) + +- "Approve without send" checkbox on `SendQuoteModal` — Dave mentioned this as a future toggle. Adds a payload flag and a separate code path on `review-approve`. Not in this commit. +- Inline markup-edit affordance on a list row — would require a new modal/dialog. Punt. +- Removing the standalone "Approve and don't send" path from the entire app (wherever else the operator "approves without sending" is a separate concern). +- Visual indicator on a list row showing whether the rep already nudged markup on the detail page (i.e. a hint that the quote is "trustworthy" for one-click). Deferrable polish. + +--- + +## Rollout + +Single deploy: + +1. Replace the two handler blocks and two button labels in `src/app/(app)/quotes/approvals/page.tsx`. +2. Manual smoke: queue a quote, click "Approve & send" on the list, confirm modal opens with the right fields, submit, verify email goes out, verify row disappears from the list. +3. Same smoke for partial row. + +--- + +## File structure + +| File | Touched | Responsibility | +|---|---|---| +| `src/app/(app)/quotes/approvals/page.tsx` | Yes — only file modified | Replace per-row handlers + button labels; lift `sendApprove`; render modal at page level keyed by row id | +| `src/components/quotes/send-quote-modal.tsx` | No | Reused unchanged | +| `src/app/api/v1/quotes/[id]/review-approve/route.ts` | No | Endpoint already supports the call | + +--- + +## Open questions + +None at design time.