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>
8.4 KiB
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:
- Click "Approve" on the row → row leaves the list with status
approved, toast says "proceed to send from the quote page" - 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
sendApprovecallback lifted to page level (same shape as the detail page'ssendApprove), 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-approvewithaction: "approve", optionalemail{}, optionalitemChanges[]— already atomically approves and sends. Already handles full + partial quotes. Already rejects onplaceholder_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 byreview-approve. The endpoint stays (used elsewhere in the app).- Direct
pb.collection("quotes").update(...)writes from the list — replaced byreview-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 onreview-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:
- Replace the two handler blocks and two button labels in
src/app/(app)/quotes/approvals/page.tsx. - 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.
- 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.