docs(quotes): add approve-and-send-from-list design spec

Lifts the detail-screen "Approve & send email" button behaviour onto
the pending-approvals list rows. Replaces the list's existing
two-step approve + drill-in flow with the existing combined
review-approve endpoint behind the same SendQuoteModal the detail
page already uses.

Out of scope: "approve without send" toggle in the modal, inline
markup-edit on a row.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-27 00:33:50 +02:00
parent 721b0f6bf7
commit d87a007be3

View file

@ -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: <msg>. 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 `"<N> 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: <msg>. 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.