WFDASH-WEBAPP/docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md
Dave 5a9e0c8a70 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 00:37:38 +02:00

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

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 (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:

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 sendApprove callback

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 handleApprove with 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 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":

{/* 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 &amp; send
</Button>

Note: &amp; 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 SendQuoteModal at 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_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.