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