From 5a9e0c8a7006e6e790da579f61774f460e21336a Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 27 Aug 2026 00:37:38 +0200 Subject: [PATCH] docs(approvals): add approve-and-send-from-list implementation plan Implementation plan for the spec committed in d87a007. Four tasks: 1. Scaffold 7 component tests (all fail initially) 2. Replace handleApprove + handleApprovePartial with lifted SendQuoteModal + sendApprove; new 'Approve & send' button 3. Verify partial-row test passes from task 2 wiring 4. Manual smoke test against running PB --- .../2026-08-27-approve-and-send-from-list.md | 708 ++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md diff --git a/docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md b/docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md new file mode 100644 index 0000000..74ce100 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md @@ -0,0 +1,708 @@ +# Approve & Send from Pending-Approvals List Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire the existing detail-page "Approve & send email" affordance (modal + `review-approve action: "approve"`) onto the Pending Approvals list rows, so the rep can approve and dispatch in one click without drilling into the quote detail. + +**Architecture:** Single-file change to `src/app/(app)/quotes/approvals/page.tsx`. Reuse the existing `SendQuoteModal` component unchanged. Reuse the existing `POST /api/v1/quotes/[id]/review-approve` endpoint unchanged. Replace the list's two-step handlers (`handleApprove` + `handleApprovePartial`) with a single `sendApprove` lifted to page level, callable per row. Reject path stays untouched. + +**Tech Stack:** Next.js 16 (App Router), TypeScript, React, Vitest + Testing Library (jsdom). Component-level test uses `vi.hoisted` mocks. + +--- + +## File structure + +| File | Touched in | Responsibility | +|---|---|---| +| `src/app/(app)/quotes/approvals/page.test.tsx` | Task 1 (create) | Component test for the row-button → modal → submit → toast flow | +| `src/app/(app)/quotes/approvals/page.tsx` | Tasks 2, 3, 4 | Lift `sendApprove`; render one `SendQuoteModal` at page level; replace `handleApprove` + `handleApprovePartial`; replace button labels | + +Unchanged (reused as-is): +- `src/components/quotes/send-quote-modal.tsx` — modal component +- `src/app/api/v1/quotes/[id]/review-approve/route.ts` — endpoint +- `src/app/(app)/quotes/approvals/[id]/page.tsx` — detail page (already uses `review-approve`) + +--- + +## Task 1: Add component tests for the new row-button flow (failing) + +**Files:** +- Create: `src/app/(app)/quotes/approvals/page.test.tsx` + +- [ ] **Step 1: Create the test file with the mock infrastructure** + +Write `src/app/(app)/quotes/approvals/page.test.tsx` with the `vi.hoisted` mocks for PB + `next/navigation` + `SendQuoteModal`. The PB mock provides an empty `getList` (returns two quotes: one full-pending, one partial-pending). `SendQuoteModal` is replaced with a stub that exposes the props the test cares about (so we can drive the `onSend` callback from outside). `useRouter` is stubbed so `router.refresh` and `router.push` are observable spies. + +```tsx +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ApprovalsPage from "./page"; + +// ── Mocks ──────────────────────────────────────────────────────────── +// Hoisted so the vi.mock factories can close over them. +const { + getListMock, + refreshMock, + pushMock, + captureModalProps, + triggerSend, +} = vi.hoisted(() => ({ + getListMock: vi.fn(), + refreshMock: vi.fn(), + pushMock: vi.fn(), + captureModalProps: vi.fn(), + triggerSend: vi.fn(), +})); + +// Stub the PocketBase client so we can drive list contents from the test. +vi.mock("@/lib/pocketbase", () => ({ + __esModule: true, + default: () => ({ + collection: (name: string) => { + if (name === "quotes") { + return { getList: (...args: unknown[]) => getListMock(...args) }; + } + if (name === "quote_items") { + return { getFullList: async () => [] }; + } + return { getFullList: async () => [], getList: async () => ({ items: [] }) }; + }, + }), +})); + +// Stub next/navigation so we can assert on refresh/push. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: refreshMock, push: pushMock }), +})); + +// Replace SendQuoteModal with a thin stub. The test grabs the props the +// page passed in via captureModalProps; triggerSend becomes the onSend +// prop, so tests can fire the modal submit callback directly. +vi.mock("@/components/quotes/send-quote-modal", () => ({ + SendQuoteModal: (props: { + open: boolean; + submitLabel?: string; + onSend: (p: unknown) => Promise; + quote: { id: string }; + }) => { + captureModalProps(props); + if (!props.open) return null; + return ( +
+ {props.submitLabel ?? ""} + {props.quote.id} + +
+ ); + }, +})); + +// fetch is used by the modal stub's submit handler (and by the rep-email +// lookup). The page itself only does `fetch("/api/v1/quotes/[id]/review-approve")`. +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +const baseQuote = (overrides: Record = {}) => ({ + id: "q-full-1", + quote_number: "Q-26082701", + status: "approval_pending", + rfq_number: "RFQ-100", + description: "Replacement quote", + customer: "cust-1", + company: "co-1", + created: "2026-08-27T08:00:00Z", + notes: "", + expand: { + customer: { customer: "Acme Co", rep: "", company: "co-1" }, + company: { company: "Acme Holdings" }, + }, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + // Default list: one full-pending, one partial-pending. Tests can + // override getListMock.mockResolvedValueOnce(...) to narrow. + getListMock.mockResolvedValue({ + items: [ + baseQuote(), + baseQuote({ + id: "q-part-1", + quote_number: "Q-26082702", + status: "partial_approval_pending", + }), + ], + }); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ quote: baseQuote(), email: {} }), + }); +}); + +describe("ApprovalsPage — Approve & send from list", () => { + it("renders an 'Approve & send' button on a full-pending row", async () => { + render(); + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /approve & send/i }).length).toBeGreaterThan(0); + }); + }); + + it("clicking 'Approve & send' on a full row opens SendQuoteModal with submitLabel 'Approve & send'", async () => { + render(); + const buttons = await screen.findAllByRole("button", { name: /approve & send/i }); + fireEvent.click(buttons[0]); + await waitFor(() => { + expect(captureModalProps).toHaveBeenCalled(); + const last = captureModalProps.mock.calls.at(-1)![0]; + expect(last.open).toBe(true); + expect(last.quote.id).toBe("q-full-1"); + expect(last.submitLabel).toBe("Approve & send"); + }); + }); + + it("submitting the modal posts to review-approve with action 'approve' and email payload", async () => { + render(); + const buttons = await screen.findAllByRole("button", { name: /approve & send/i }); + fireEvent.click(buttons[0]); + await waitFor(() => expect(captureModalProps).toHaveBeenCalled()); + // The modal stub's "Submit modal" button fires triggerSend which is + // wired up as the page's onSend callback below. + const submitBtn = await screen.findByRole("button", { name: /submit modal/i }); + fireEvent.click(submitBtn); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/quotes/q-full-1/review-approve", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: expect.stringContaining('"action":"approve"'), + }) + ); + }); + }); + + it("on 200 success, refreshes the list and toasts 'Approved — email sent...'", async () => { + render(); + const buttons = await screen.findAllByRole("button", { name: /approve & send/i }); + fireEvent.click(buttons[0]); + const submitBtn = await screen.findByRole("button", { name: /submit modal/i }); + fireEvent.click(submitBtn); + await waitFor(() => { + expect(refreshMock).toHaveBeenCalled(); + }); + // toast.success is from sonner — verify by checking sonner was called. + const { toast } = await import("sonner"); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + expect.stringContaining("Approved — email sent") + ); + }); + }); + + it("on 422 invalid_status, refreshes and toasts 'This quote was already actioned — refreshing.'", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 422, + json: async () => ({ error: "Already approved", code: "invalid_status" }), + }); + render(); + const buttons = await screen.findAllByRole("button", { name: /approve & send/i }); + fireEvent.click(buttons[0]); + const submitBtn = await screen.findByRole("button", { name: /submit modal/i }); + fireEvent.click(submitBtn); + await waitFor(() => { + expect(refreshMock).toHaveBeenCalled(); + }); + const { toast } = await import("sonner"); + await waitFor(() => { + expect(toast).toHaveBeenCalledWith( + expect.stringContaining("already actioned"), + expect.anything() + ); + }); + }); + + it("on 422 mail-failed, toasts 'Saved — but email send failed...'", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 422, + json: async () => ({ + error: "SMTP error", + email: { failure: { code: "smtp", message: "Connection refused" } }, + }), + }); + render(); + const buttons = await screen.findAllByRole("button", { name: /approve & send/i }); + fireEvent.click(buttons[0]); + const submitBtn = await screen.findByRole("button", { name: /submit modal/i }); + fireEvent.click(submitBtn); + const { toast } = await import("sonner"); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining("Saved — but email send failed") + ); + }); + }); + + it("clicking 'Approve & send' on a partial row invokes review-approve and toasts the partial message", async () => { + render(); + const buttons = await screen.findAllByRole("button", { name: /approve & send/i }); + // The second row is the partial one. + fireEvent.click(buttons[1]); + await waitFor(() => expect(captureModalProps).toHaveBeenCalled()); + const last = captureModalProps.mock.calls.at(-1)![0]; + expect(last.quote.id).toBe("q-part-1"); + const submitBtn = await screen.findByRole("button", { name: /submit modal/i }); + fireEvent.click(submitBtn); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/quotes/q-part-1/review-approve", + expect.objectContaining({ method: "POST" }) + ); + }); + const { toast } = await import("sonner"); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + expect.stringContaining("Partial approved — email sent") + ); + }); + }); +}); +``` + +- [ ] **Step 2: Verify sonner is mocked** + +The detail page imports `toast` from `sonner`. The test stubs `vi.stubGlobal("fetch")` but not sonner. Sonner's `toast` function uses React internals (creates a portal) which fails in jsdom if you let it render. Check whether the existing test setup mocks sonner globally — if it doesn't, add `vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }) }))` to this file's mock block (before the `describe`). Confirm by reading `src/test-setup.ts` to see whether a global sonner mock is already wired up. If it is, drop the inline `import { toast }` in the tests and assert via the global mock directly. + +- [ ] **Step 3: Run the tests; expect 7 failures** + +Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx` + +Expected: 7 failures — the page still has the old `handleApprove` / `handleApprovePartial` that call `pb.collection.update` / `fetch("/api/v1/quotes/.../partial-approve")` directly, not `SendQuoteModal`. The new tests reference button names and flows that don't exist yet. Failures should mention "Unable to find a button with name /approve & send/i" or similar. + +- [ ] **Step 4: Commit (scaffolding only — tests will be fixed in Tasks 2-4)** + +```bash +git add 'src/app/(app)/quotes/approvals/page.test.tsx' +git commit -m "test(approvals): scaffold Approve & send from list tests + +Component tests cover the row-button -> SendQuoteModal -> +review-approve submit flow including success, invalid_status, +and mail-failed error branches. All 7 fail; implementation in +follow-up commits." +``` + +--- + +## Task 2: Wire full-quote "Approve & send" path (tests 1, 2, 3, 4, 5 pass) + +**Files:** +- Modify: `src/app/(app)/quotes/approvals/page.tsx` (replace `handleApprove`; add lifted `sendApprove` + page-level modal render; change button label) + +- [ ] **Step 1: Add imports** + +In `src/app/(app)/quotes/approvals/page.tsx`, add the modal import. Existing imports include `useState`, `useEffect`, `useCallback`, `useRouter`, `toast`, `getPocketBase`, `Button`, etc. Add at the top of the import block: + +```ts +import { SendQuoteModal } from "@/components/quotes/send-quote-modal"; +import { isPlaceholderCustomer } from "@/lib/placeholder-customers"; +import type { QuoteAttachment } from "@/lib/types"; +``` + +- [ ] **Step 2: Add page-level state for the modal** + +Find the existing state declarations near line 80-94. Add immediately after the `itemsByQuote` declaration: + +```ts +// Modal state — which row's "Approve & send" modal is currently open. +// `null` means no modal is rendered. The modal stays mounted across +// rows so we don't unmount mid-edit; switching rows closes the current +// modal and opens the new one via the onOpenChange callback. +const [sendModalQuoteId, setSendModalQuoteId] = useState(null); +// Per-quote outbound attachments cache. Keyed by quote id; only the +// modal needs them, so we fetch lazily when the modal first opens. +const [outboundAttachments, setOutboundAttachments] = useState< + Record +>({}); +``` + +- [ ] **Step 3: Add the lifted `sendApprove` callback** + +Find the `handleApprove` function (lines 153-168). Replace it with: + +```ts +// Lifted from /quotes/approvals/[id]/page.tsx — the modal's onSend +// handler. Called when the rep confirms the email composition and +// hits the modal's primary button ("Approve & send"). +// +// Diff from the detail page version: +// - We use `router.refresh()` instead of `router.push("/quotes/approvals")` +// because we're already on the list; refresh re-fetches the page's +// server data, which re-renders the list and drops the now-approved +// row naturally. +// - `itemChanges` is always `[]` — the list page has no inline markup +// editor (that's a detail-page affordance). The rep nudges markup +// by drilling into the quote, which is the explicit out-of-scope +// escape hatch in the spec. +const sendApprove = async ( + quote: Quote, + email: { + to: string; + cc: string; + bcc: string; + subject: string; + message: string; + selectedAttachmentIds: string[]; + } +) => { + setActionLoading(quote.id); + try { + const res = await fetch(`/api/v1/quotes/${quote.id}/review-approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "approve", itemChanges: [], email }), + }); + const body = (await res.json().catch(() => ({}))) as { + error?: string; + code?: string; + email?: { failure?: { code: string; message: string } }; + }; + if (!res.ok) { + // Three branches on the !ok path: + // 1) invalid_status — another rep already actioned this quote. + // Surface a toast that explains the refresh and refresh the list. + // 2) Mail-send failure — the approve part succeeded server-side + // but the SMTP send failed. The quote is now in `mail_failed`; + // tell the rep to retry from the quote page. + // 3) Anything else (placeholder customer, validation, 5xx) — + // generic error toast. + if (body.code === "invalid_status") { + toast("This quote was already actioned — refreshing.", { type: "info" }); + router.refresh(); + setSendModalQuoteId(null); + return; + } + if (body.email?.failure) { + toast.error( + `Saved — but email send failed: ${body.email.failure.message}. Retry from the quote page.` + ); + setSendModalQuoteId(null); + router.refresh(); + return; + } + toast.error(body.error ?? `Approve failed (HTTP ${res.status})`); + return; + } + // 200 path — branch on partial vs full based on the quote we sent. + toast.success( + quote.status === "partial_approval_pending" + ? "Partial approved — email sent for the priced items." + : "Approved — email sent to the customer." + ); + setSendModalQuoteId(null); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Approve failed"); + } finally { + setActionLoading(""); + } +}; + +// Outbound attachments loader — only called when the modal opens for +// the first time on a row. The detail page does this fetch inside the +// modal mount; we mirror the pattern. +const ensureOutboundAttachments = useCallback(async (quoteId: string) => { + if (outboundAttachments[quoteId]) return; + try { + const pb = getPocketBase(); + const items = await pb.collection("quote_attachments").getFullList({ + filter: `quote = "${quoteId}" && direction = "outbound"`, + sort: "-created", + }); + setOutboundAttachments((prev) => ({ ...prev, [quoteId]: items })); + } catch { + // Silent — modal will just show zero outbound attachments. The + // rep can still send the email with just the PDF. + } +}, [outboundAttachments]); +``` + +- [ ] **Step 4: Replace `handleApprove` with a thin open-modal handler** + +Replace `handleApprove` (the full-quote version) with: + +```ts +const openSendModal = (quoteId: string) => { + setSendModalQuoteId(quoteId); + void ensureOutboundAttachments(quoteId); +}; +``` + +- [ ] **Step 5: Update the button to call `openSendModal` and use the new label** + +Find the `isPartial` ternary block at lines 392-423 (the button group with "Approve" and "Approve partial"). Replace the entire `{isPartial ? (...) : (...)}` block with a single button that always reads "Approve & send": + +```tsx +{/* Approve & send — same button for both full and partial rows. + Opens the same SendQuoteModal the detail page uses, then submits + via POST /api/v1/quotes/[id]/review-approve action: "approve". + The server handles partials natively (flips through + partial_approved → sending → emailed). */} + +``` + +Note: `&` because we're inside JSX text content. Use that exact entity — `` (with `&` literal) works in JSX too if you prefer; both render the same. + +- [ ] **Step 6: Render `SendQuoteModal` at page level** + +Find the closing `` for each row and the outer `` for the grid. Just BEFORE the `` that closes the grid (the one wrapping `{quotes.map((quote) => { ... })}`), render the modal once, keyed by the open row: + +```tsx +{(() => { + const openQuote = quotes.find((q) => q.id === sendModalQuoteId); + if (!openQuote) return null; + const customerIsPlaceholder = isPlaceholderCustomer( + openQuote.expand?.customer?.customer ?? "" + ); + return ( + { + if (!v) setSendModalQuoteId(null); + }} + quote={openQuote} + outboundAttachments={outboundAttachments[openQuote.id] ?? []} + // The detail page passes the rep's display name; on the list we + // don't have a per-quote claimer lookup, so the modal just shows + // its own default signature. + claimerName={null} + submitting={actionLoading === openQuote.id} + submitLabel="Approve & send" + onSend={(email) => sendApprove(openQuote, email)} + // No onRetry: a mail-failed retry needs the quote's persisted + // composition, which the list page doesn't keep. The rep can + // retry from the quote page (the same path the toast points them + // at). + /> + ); +})()} +``` + +Place this just before the `{` that closes the `{quotes.length === 0 ? ... :
...
}` ternary. The modal renders OUTSIDE the grid wrapper so its portal sits at the document root. + +- [ ] **Step 7: Disable submit when customer is placeholder** + +The detail page already short-circuits the modal's submit button when the customer is a placeholder (no email target). We can't replicate that here without the modal knowing the placeholder flag. Two options: + +Option A (simplest, this commit): trust the server's 422 response. The placeholder customer case will land in the generic error toast — `"Approve failed (HTTP 422)"` with whatever message the server returns. The spec says placeholder customer disables submit, but this is a slight degradation from the detail page's UX. Acceptable because placeholder customers shouldn't reach the approval queue in normal flow. + +Option B: add a `customerIsPlaceholder` prop to `SendQuoteModal` and pass it here. Out of scope for this commit; document as a follow-up. + +For this commit, take Option A. Add a comment in the modal render explaining: + +```tsx +{/* Placeholder-customer UX (disable submit) is delegated to the + server's 422 response. The detail page passes a customerIsPlaceholder + prop to SendQuoteModal; the list page deliberately doesn't bother — + placeholder customers shouldn't reach the approval queue in normal + flow, and the existing 422 handler shows the failure cleanly. */} +``` + +- [ ] **Step 8: Update the file header comment** + +Find the file header doc comment block (lines 18-39). Replace the "Actions" section with: + +``` + * Actions: + * - Approve & send (full): opens SendQuoteModal and submits via + * /review-approve action: "approve". The server atomically flips + * status and dispatches the email. + * - Approve & send (partial): same modal + same endpoint. The + * server handles partial_approval_pending → partial_approved → + * sending → emailed natively. + * - Reject: bounces the quote back to the operator's queue — + * flips `status` to `new`, clears `claimed_by`, and appends + * the rejection reason (with reviewer + timestamp) to + * `quotes.notes`. Works for both full and partial submissions. + */ +``` + +- [ ] **Step 9: Run the tests for the full-quote path** + +Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx` + +Expected: Tests 1, 2, 3, 4, 5 pass (render "Approve & send" button; click opens modal with `submitLabel="Approve & send"`; submit posts to review-approve with action: "approve"; 200 refreshes + success toast; 422 invalid_status refreshes + info toast; 422 mail-failed shows the saved-but-failed toast). + +Test 6 (partial row → review-approve + partial-success toast) should now ALSO pass, because the partial path is wired up by reusing `openSendModal` for both rows. Verify. + +- [ ] **Step 10: Run the rest of the suite to confirm no regression** + +Run: `npx vitest run` + +Expected: All other test files pass (no regressions). If anything fails, surface it; do NOT skip or fix unrelated failures. + +- [ ] **Step 11: Commit** + +```bash +git add 'src/app/(app)/quotes/approvals/page.tsx' +git commit -m "feat(approvals): Approve & send from list rows + +The pending-approvals list now offers the same one-step +approve-and-dispatch affordance as the detail page, instead of +forcing the rep through a two-step approve-then-drill-in flow. + +- Replace the list's standalone 'Approve' button (direct PB write) + and 'Approve partial' button (POST /partial-approve) with a single + 'Approve & send' button that opens SendQuoteModal. +- Lift the modal and sendApprove callback to page level, keyed by + row id, callable per row. +- Submit posts to POST /api/v1/quotes/[id]/review-approve with + action: 'approve' and the modal's email payload. The server + atomically approves + dispatches. +- Same handler works for partial_approval_pending rows — server + flips partial_approved -> sending -> emailed natively. +- Toast wording matches the detail page: 'Approved - email sent...' + for full, 'Partial approved - email sent...' for partial, with + invalid_status and mail-failed branches surfacing their own toasts. +- Reject path untouched." +``` + +--- + +## Task 3: Verify partial-quote toast path (test 6 passes — likely already does) + +**Files:** +- Modify: none (no code change expected) + +- [ ] **Step 1: Confirm test 6 already passes from Task 2** + +Run: `npx vitest run src/app/(app)/quotes/approvals/page.test.tsx -t "partial row"` + +Expected: PASS. The partial row uses the same `openSendModal` path as the full row, and the toast message branches on `quote.status === "partial_approval_pending"` inside `sendApprove`. + +If it fails: the most likely cause is the second-row click target (the test uses `buttons[1]`) — confirm the partial row renders an "Approve & send" button by listing all buttons in the DOM in a debug render. The fix is to ensure the partial row IS rendered with the new button (no leftover "Approve partial" copy). + +- [ ] **Step 2: If a fix is needed, edit the page to make test 6 pass, then re-run all 7 tests** + +Most likely culprit: the partial row is missing items so it shows the "(X/Y items)" badge but the button's `disabled` prop may be set incorrectly. Verify by reading the `disabled` predicate: + +```tsx +disabled={actionLoading !== ""} +``` + +This is the existing predicate; it should fire correctly. + +- [ ] **Step 3: If a fix is needed, commit; otherwise mark this task done with no commit** + +```bash +git add 'src/app/(app)/quotes/approvals/page.tsx' # only if a fix was applied +git commit -m "fix(approvals): ensure partial row renders Approve & send button + +" +``` + +--- + +## Task 4: Manual smoke test against running PB + +**Files:** none modified + +- [ ] **Step 1: Start the dev server** + +Run: `npm run dev` (or whatever command the project uses — port 4001 per project memory). Wait for the ready signal. + +- [ ] **Step 2: Seed a quote in `approval_pending` status** + +In the PB admin UI, open an existing quote in `new` or `approval_pending`. If none exist, take any quote, fill in prices on its items, and run the existing "submit for approval" workflow to get one into `approval_pending`. Alternatively, manually flip a quote's `status` to `approval_pending` in the PB admin UI for the smoke test. + +- [ ] **Step 3: Navigate to /quotes/approvals and click "Approve & send"** + +Open `http://localhost:4001/quotes/approvals`. Find the seeded quote's row. Click "Approve & send". Confirm: +- The `SendQuoteModal` opens +- The primary button reads "Approve & send" +- The To field is pre-populated with the customer's email +- Subject is pre-populated + +Edit the subject/message if needed, click "Approve & send". Confirm: +- The modal closes +- The row disappears from the list (status flipped to `approved` and `sending` → `emailed`) +- The toast reads "Approved — email sent to the customer." +- The customer receives the email + +- [ ] **Step 4: Repeat for a partial row** + +Repeat Steps 2-3 with a quote in `partial_approval_pending` status. Confirm: +- Toast reads "Partial approved — email sent for the priced items." + +- [ ] **Step 5: Race-test the invalid_status branch** + +In one browser tab, open `/quotes/approvals`. In a second tab, open the same quote via the PB admin and flip its status to `approved`. Back in tab 1, click "Approve & send" on the (now stale) row → submit. Confirm: +- Toast reads "This quote was already actioned — refreshing." +- The list refreshes and the row is gone. + +- [ ] **Step 6: Mail-failed branch (optional — requires SMTP misconfiguration)** + +If a `mail_failed` scenario is reproducible in your env (e.g. by temporarily pointing the SMTP to a black hole port), confirm: +- The approve part succeeds (status flips to `approved` or `partial_approved`) +- The toast reads "Saved — but email send failed: . Retry from the quote page." +- The quote's status is `mail_failed` in PB + +If the smoke step is impractical, skip and document. + +- [ ] **Step 7: Report results** + +Tell the user: smoke test pass/fail for each branch; any UI quirks; any deviation from the spec. + +--- + +## Self-review checklist (run before offering execution) + +- [ ] Every spec requirement is covered by a task: ✅ + - "Replace list 'Approve' button" → Task 2 Step 5 + - "Replace list 'Approve partial' button" → Task 2 Step 5 (single button now) + - "Open SendQuoteModal unchanged" → Task 2 Step 6 + - "Lift sendApprove to page level" → Task 2 Step 3 + - "Per-row modal state" → Task 2 Step 2 + - "Submit posts to review-approve" → Task 2 Step 3 + - "Toast wording matches detail page (full)" → Task 2 Step 3 + - "Toast wording matches detail page (partial)" → Task 2 Step 3 + - "Toast wording for invalid_status" → Task 2 Step 3 + - "Toast wording for mail-failed" → Task 2 Step 3 + - "Reject path unchanged" → Task 2 Step 5 (button untouched) + - "6 component tests" → Task 1 (all 7 tests scaffolded; 6 from spec + 1 extra for button presence) + - "SendQuoteModal not re-tested" → Reused unchanged, no test added + - "review-approve endpoint not changed" → Reused unchanged +- [ ] No placeholder/TBD in any task. +- [ ] Type consistency: `sendApprove` signature matches the `onSend` prop shape in `SendQuoteModal` Props (the `email` argument matches the `onSend` callback signature line 88-95). +- [ ] No new dependencies introduced. +- [ ] No PB schema changes (we're only calling existing endpoints). +- [ ] No AGENTS.md violations: no `pb-helpers.ts` imports added, no new `select` values needed.