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
30 KiB
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 componentsrc/app/api/v1/quotes/[id]/review-approve/route.ts— endpointsrc/app/(app)/quotes/approvals/[id]/page.tsx— detail page (already usesreview-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.
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<void>;
quote: { id: string };
}) => {
captureModalProps(props);
if (!props.open) return null;
return (
<div data-testid="send-quote-modal">
<span data-testid="modal-submit-label">{props.submitLabel ?? ""}</span>
<span data-testid="modal-quote-id">{props.quote.id}</span>
<button
type="button"
onClick={() =>
triggerSend({
to: "buyer@example.com",
cc: "",
bcc: "",
subject: "Quote Q-26082701",
message: "",
selectedAttachmentIds: [],
})
}
>
Submit modal
</button>
</div>
);
},
}));
// 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<string, unknown> = {}) => ({
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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)
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(replacehandleApprove; add liftedsendApprove+ 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:
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:
// 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<string | null>(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<string, QuoteAttachment[]>
>({});
- Step 3: Add the lifted
sendApprovecallback
Find the handleApprove function (lines 153-168). Replace it with:
// 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<QuoteAttachment>({
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
handleApprovewith a thin open-modal handler
Replace handleApprove (the full-quote version) with:
const openSendModal = (quoteId: string) => {
setSendModalQuoteId(quoteId);
void ensureOutboundAttachments(quoteId);
};
- Step 5: Update the button to call
openSendModaland 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":
{/* 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). */}
<Button
size="sm"
variant={isPartial ? "default" : "default"}
onClick={(e) => {
e.stopPropagation();
openSendModal(quote.id);
}}
disabled={actionLoading !== ""}
>
{actionLoading === quote.id && (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
)}
<CheckCircle className="mr-1 h-3 w-3" />
Approve & send
</Button>
Note: & because we're inside JSX text content. Use that exact entity — <Button>Approve & send</Button> (with & literal) works in JSX too if you prefer; both render the same.
- Step 6: Render
SendQuoteModalat page level
Find the closing </Card> for each row and the outer </div> for the grid. Just BEFORE the </div> that closes the grid (the one wrapping {quotes.map((quote) => { ... })}), render the modal once, keyed by the open row:
{(() => {
const openQuote = quotes.find((q) => q.id === sendModalQuoteId);
if (!openQuote) return null;
const customerIsPlaceholder = isPlaceholderCustomer(
openQuote.expand?.customer?.customer ?? ""
);
return (
<SendQuoteModal
open={true}
onOpenChange={(v) => {
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 ? ... : <div className="grid gap-4"> ... </div>} 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:
{/* 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
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:
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
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
<one-line explanation of what was actually wrong>"
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_pendingstatus
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
SendQuoteModalopens - 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
approvedandsending→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
approvedorpartial_approved) - The toast reads "Saved — but email send failed: . Retry from the quote page."
- The quote's status is
mail_failedin 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:
sendApprovesignature matches theonSendprop shape inSendQuoteModalProps (theemailargument matches theonSendcallback 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.tsimports added, no newselectvalues needed.