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
This commit is contained in:
parent
d87a007be3
commit
5a9e0c8a70
1 changed files with 708 additions and 0 deletions
708
docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md
Normal file
708
docs/superpowers/plans/2026-08-27-approve-and-send-from-list.md
Normal file
|
|
@ -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<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)**
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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:
|
||||
|
||||
```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). */}
|
||||
<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 `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:
|
||||
|
||||
```tsx
|
||||
{(() => {
|
||||
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:
|
||||
|
||||
```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
|
||||
|
||||
<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: <msg>. 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.
|
||||
Loading…
Reference in a new issue