From c3e9c74d6129e41f69e43e24811a3b0fcf4ace05 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 26 Aug 2026 23:13:02 +0200 Subject: [PATCH] docs(quotes): add no-quote feature implementation plan 11 tasks covering schema, type, PATCH endpoint, three service gate relaxations, PDF route bypass, PDF component rendering, UI toggle on Quote Details card, MCP tool exposure, and an E2E smoke test. TDD throughout with file:line citations to the existing service tests as templates. Co-Authored-By: Claude --- docs/superpowers/plans/2026-08-26-no-quote.md | 1008 +++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-no-quote.md diff --git a/docs/superpowers/plans/2026-08-26-no-quote.md b/docs/superpowers/plans/2026-08-26-no-quote.md new file mode 100644 index 0000000..9416efc --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-no-quote.md @@ -0,0 +1,1008 @@ +# No-Quote Feature 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:** Add the ability for an operator to send an official decline letter on a quote — a quote that the company cannot price — using the existing PDF and email pipeline. + +**Architecture:** Single boolean `is_no_quote` flag on the `quotes` collection. When true, the four pricing gates (submit, diagnose-pdf-readiness, PDF route `ready.length===0` short-circuit, send-email gate #6) relax. PDF renders items with "Not quoted" cells; the existing `quote_notes` field carries the reason and renders as a callout titled "Why we cannot quote." + +**Tech Stack:** Next.js 16 (App Router), TypeScript, PocketBase, Vitest, React + Tailwind + `@base-ui/react/switch`, jsPDF for PDF rendering. TDD throughout. + +--- + +## File structure + +Files this plan touches: + +| File | Responsibility | Touched in | +|---|---|---| +| `pb-exports/pb_schema-2.json` | Local PB schema snapshot. Add `is_no_quote` bool field. | Task 1 | +| `src/lib/types.ts` | `Quote` interface. Add `is_no_quote?: boolean`. | Task 2 | +| `src/app/api/v1/quotes/[id]/route.ts` | PATCH endpoint. Add `is_no_quote` to `PATCHABLE_FIELDS`. Add `setIsNoQuote` setter. | Task 3 | +| `src/lib/server/quotes-service.ts` | `submitQuote`, `diagnosePdfReadiness`, new `setIsNoQuote` helper. Relax gates when flag is true. | Tasks 4, 5, 6 | +| `src/app/api/v1/quotes/[id]/pdf/route.ts` | PDF render route. Skip `ready.length === 0` gate when `is_no_quote`. Pass `noQuote` to PDF component. | Task 7 | +| `src/lib/pdf/quote-document.tsx` | PDF document. Add `noQuote?: boolean` prop. Conditional per-item cells, totals, callout header. | Task 8 | +| `src/app/(app)/quotes/[id]/process/page.tsx` | Process page. Add Switch + handler to Quote Details card. Scroll into view of quote_notes editor. | Task 9 | +| `src/app/api/mcp/route.ts` | MCP tools. Expose `is_no_quote` on `quotes.create` and `quotes.update`. | Task 10 | + +Existing test files used as patterns: +- `src/lib/server/quotes-service.test.ts` — has a sophisticated vi.hoisted mock with a FilterAST parser; tests skip PB and exercise pure logic. Read this first to understand the mock setup before writing new tests. +- `src/app/api/v1/quotes/[id]/route.test.ts` — mocks the service layer with `vi.mock` and calls the route handler directly. Model new route tests on this. + +--- + +## Task 1: Add `is_no_quote` to PB schema (admin + snapshot) + +**Files:** +- Modify: `pb-exports/pb_schema-2.json` (add field to `quotes` collection) + +- [ ] **Step 1: Add field to the running PB instance via the PB admin UI** + +Open the PB admin UI in a browser. Navigate to the `quotes` collection → Edit collection → Fields → New field. Configure: + +- **Name:** `is_no_quote` +- **Type:** `bool` +- **Required:** false +- **Default:** `false` +- **Help:** "True if this quote is being sent as a 'we cannot quote' decline letter. When set, pricing gates relax and the PDF renders items with 'Not quoted' labels." + +Click "Save". Verify the field appears in the collection schema list. + +- [ ] **Step 2: Update local schema snapshot** + +Run the PB schema export command (find it in the project's `scripts/` directory or `package.json` scripts). If no export command exists, manually add the field to `pb-exports/pb_schema-2.json` inside the `quotes` collection's `fields` array. Use this exact shape (modeled on `bool_is_template` at line 2168): + +```json +{ + "help": "True if this quote is being sent as a 'we cannot quote' decline letter. When set, pricing gates relax and the PDF renders items with 'Not quoted' labels.", + "hidden": false, + "id": "bool_is_no_quote", + "name": "is_no_quote", + "presentable": false, + "required": false, + "system": false, + "type": "bool" +} +``` + +Add it immediately after the `bool_is_template` entry. + +- [ ] **Step 3: Verify by reading a quote** + +Start the dev server (if not running). In the PB admin UI, open any existing quote record and verify the new `is_no_quote` checkbox appears in the form, defaulting to unchecked. Toggle it, save, and confirm the value persists. + +- [ ] **Step 4: Commit** + +```bash +git add pb-exports/pb_schema-2.json +git commit -m "feat(quotes): add is_no_quote boolean to schema + +Required to mark a quote as an official decline letter. Gates relax +when set so operators can send a quote with zero prices on letterhead. +Field added to PB admin manually (UI-only change per AGENTS.md). +" +``` + +--- + +## Task 2: Add `is_no_quote` to the TypeScript `Quote` interface + +**Files:** +- Modify: `src/lib/types.ts:240-280` (the `Quote` interface — find the exact line by searching for `export interface Quote`) + +- [ ] **Step 1: Locate the Quote interface** + +```bash +grep -n "export interface Quote" src/lib/types.ts +``` + +Open the file at that line. Find the existing `is_template?: boolean;` field (or a similar boolean field nearby — search the file with `grep -n "is_template" src/lib/types.ts`). + +- [ ] **Step 2: Add the field** + +Add `is_no_quote?: boolean;` immediately after the `is_template` field. Match the surrounding style (semicolons, indentation, optional marker). + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +npx tsc --noEmit +``` + +Expected: zero new errors. If errors mention `is_no_quote` being used elsewhere, fix those call sites — but at this stage only the type is added, so there should be no other references yet. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/types.ts +git commit -m "feat(quotes): add is_no_quote?: boolean to Quote interface + +Mirrors the is_template boolean. Wires the new field through the +type system before any service-layer code touches it. +" +``` + +--- + +## Task 3: Add `is_no_quote` to the PATCH endpoint whitelist (with test) + +**Files:** +- Modify: `src/app/api/v1/quotes/[id]/route.ts:33-36` +- Test: `src/app/api/v1/quotes/[id]/route.test.ts` (extend existing file) + +Read the existing test file fully first to understand the mocking pattern. The current test mocks `@/lib/server/quotes-service` and exposes only `setQuoteNotes`. We need to mock the new `setIsNoQuote` too. + +- [ ] **Step 1: Write the failing test** + +Add to `src/app/api/v1/quotes/[id]/route.test.ts` — first update the mock to include the new setter, then add new test cases. Replace the top of the file's mock setup with: + +```ts +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const setQuoteNotesMock = vi.fn(); +const setIsNoQuoteMock = vi.fn(); + +vi.mock("@/lib/server/quotes-service", () => ({ + setQuoteNotes: (...args: unknown[]) => setQuoteNotesMock(...args), + setIsNoQuote: (...args: unknown[]) => setIsNoQuoteMock(...args), +})); + +import { PATCH } from "./route"; +``` + +(If the existing test file already has a more elaborate setup, just add `setIsNoQuoteMock` and `setIsNoQuote` to the existing mock — don't rewrite the whole file.) + +Then add the new test cases at the bottom of the existing `describe("PATCH /api/v1/quotes/[id]", ...)` block: + +```ts +describe("PATCH /api/v1/quotes/[id] is_no_quote", () => { + beforeEach(() => { + setIsNoQuoteMock.mockReset(); + }); + + it("accepts is_no_quote: true and calls setIsNoQuote", async () => { + setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: true }); + const res = await callPATCH({ is_no_quote: true }); + expect(res.status).toBe(200); + expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", true); + }); + + it("accepts is_no_quote: false and calls setIsNoQuote", async () => { + setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: false }); + const res = await callPATCH({ is_no_quote: false }); + expect(res.status).toBe(200); + expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", false); + }); + + it("rejects is_no_quote: \"yes\" (non-boolean)", async () => { + const res = await callPATCH({ is_no_quote: "yes" }); + expect(res.status).toBe(400); + expect(setIsNoQuoteMock).not.toHaveBeenCalled(); + }); + + it("can patch is_no_quote and quote_notes in the same call", async () => { + setQuoteNotesMock.mockResolvedValue({ id: "q-123", quote_notes: "Out of scope" }); + setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: true }); + const res = await callPATCH({ is_no_quote: true, quote_notes: "Out of scope" }); + expect(res.status).toBe(200); + expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", true); + expect(setQuoteNotesMock).toHaveBeenCalledWith("q-123", "Out of scope"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/app/api/v1/quotes/[id]/route.test.ts +``` + +Expected: the new tests fail. Specifically: +- "accepts is_no_quote: true" — fails with `"is_no_quote" is not patchable` (the current whitelist rejects it) +- "rejects is_no_quote: yes" — currently returns 400 too, but with the wrong error message (whitelist rejection, not type rejection) + +- [ ] **Step 3: Implement the whitelist + setter plumbing** + +In `src/app/api/v1/quotes/[id]/route.ts`, replace the `PATCHABLE_FIELDS` constant (around line 33): + +```ts +const PATCHABLE_FIELDS = ["quote_notes", "is_no_quote"] as const; +``` + +Update the `PatchableField` type to derive from this array (the existing `type PatchableField = (typeof PATCHABLE_FIELDS)[number]` already does this — no change needed). + +Update the import on line 3 to include `setIsNoQuote`: + +```ts +import { setQuoteNotes, setIsNoQuote } from "@/lib/server/quotes-service"; +``` + +Add a validation block after the existing `quote_notes` type check (around line 70): + +```ts +if ("is_no_quote" in body && typeof body.is_no_quote !== "boolean") { + return NextResponse.json( + { error: "is_no_quote must be a boolean" }, + { status: 400 }, + ); +} +``` + +Update the body of the PATCH handler to also dispatch `setIsNoQuote` when present. Find the existing dispatch block (around line 95) and replace with: + +```ts +try { + let quote: Awaited> | undefined; + + if (typeof body.quote_notes === "string") { + quote = await setQuoteNotes(quoteId, body.quote_notes); + } + if (typeof body.is_no_quote === "boolean") { + quote = await setIsNoQuote(quoteId, body.is_no_quote); + } + + if (!quote) { + return NextResponse.json( + { error: "No patchable fields supplied" }, + { status: 400 }, + ); + } + + return NextResponse.json({ quote }); +} catch (err) { + // ...existing catch block unchanged +} +``` + +**Note:** This task adds the PATCH endpoint wiring AND the `setIsNoQuote` service helper. Both ship in this commit. Add the helper near the existing `setQuoteNotes` export in `quotes-service.ts`: + +```ts +/** + * Set or clear the no-quote flag on a quote. Reverts to false (a real + * quote) when called with `false`. Stamps `updated` automatically. + */ +export async function setIsNoQuote( + quoteId: string, + value: boolean, +): Promise { + return pb.collection("quotes").update(quoteId, { is_no_quote: value }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/app/api/v1/quotes/[id]/route.test.ts +``` + +Expected: all tests pass, including the new `is_no_quote` cases and the existing `quote_notes` tests (regression). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/v1/quotes/[id]/route.ts src/app/api/v1/quotes/[id]/route.test.ts src/lib/server/quotes-service.ts +git commit -m "feat(quotes): expose is_no_quote on PATCH /api/v1/quotes/[id] + +Adds the new field to the PATCHABLE_FIELDS whitelist, validates +type as boolean, and dispatches to a new setIsNoQuote service helper. +Tested via mocked route handler. +" +``` + +--- + +## Task 4: Relax `submitQuote` unpriced-items gate when `is_no_quote` (with test) + +**Files:** +- Modify: `src/lib/server/quotes-service.ts` (the `submitQuote` function — currently at ~line 2519-2542 per the exploration; verify exact location with grep) +- Test: `src/lib/server/quotes-service.test.ts` (extend existing file) + +Read the existing `quotes-service.test.ts` mock setup first — the file uses `vi.hoisted` with a FilterAST parser. New tests must work within that mock's constraints. + +- [ ] **Step 1: Read the existing test setup** + +```bash +wc -l src/lib/server/quotes-service.test.ts +head -100 src/lib/server/quotes-service.test.ts +``` + +Understand the `pbMock` and `clearStore` helpers. Find an existing test that exercises a service function with a mock quote — model new tests on its shape. + +- [ ] **Step 2: Locate `submitQuote` and the existing unpriced-items gate** + +```bash +grep -n "submitQuote\|SubmitQuoteError\|unpriced_items" src/lib/server/quotes-service.ts +``` + +Read the function from the `export async function submitQuote` line down to the closing brace. Identify: +- Where the quote header is loaded (so we know how to access `quote.is_no_quote`) +- The exact line of the `if (unpricedItems.length > 0 && !options?.partial)` check +- The `SubmitQuoteError` constructor signature + +- [ ] **Step 3: Write the failing test** + +Add a new `describe("submitQuote with is_no_quote", ...)` block to `quotes-service.test.ts`. The test must: + +1. Insert a mock quote into the store with `is_no_quote: true` and `id: "q-test"`. +2. Insert two mock quote_items — one with `selling_price: 0` and one with `selling_price: 100`. Filter them by `quote = "q-test"`. +3. Call `submitQuote("q-test", undefined, { partial: false })`. +4. Assert it does NOT throw (status should flip to `approval_pending`). +5. Reset. Repeat with `is_no_quote: false` — assert it DOES throw with `code === "unpriced_items"`. + +Read the existing test that exercises a similar scenario (search for `submitQuote` in the test file — there's likely an existing test that asserts the gate fires). Model the new test on that pattern. + +If no existing `submitQuote` test exists, the test will need a more elaborate setup. In that case, find a test that exercises any service function that calls `pb.collection().update()` and use that as a template. + +- [ ] **Step 4: Run the test to verify it fails** + +```bash +npx vitest run src/lib/server/quotes-service.test.ts -t "submitQuote with is_no_quote" +``` + +Expected: the `is_no_quote: true` test fails because the gate still throws when it should pass. + +- [ ] **Step 5: Modify the gate** + +In `quotes-service.ts`, locate the `if (unpricedItems.length > 0 && !options?.partial)` check. The quote header is already in scope as the local variable `quote` (used a few lines above for the status check). Add a third condition `&& !quote.is_no_quote`: + +```ts +if ( + unpricedItems.length > 0 && + !options?.partial && + !quote.is_no_quote +) { + throw new SubmitQuoteError( + `Cannot submit: ${offendingItemIds.length} item(s) have selling_price = 0...`, + 422, + "unpriced_items", + offendingItemIds, + ); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +npx vitest run src/lib/server/quotes-service.test.ts -t "submitQuote" +``` + +Expected: all `submitQuote` tests pass (new ones plus any existing ones). Run the full file to verify no regressions: + +```bash +npx vitest run src/lib/server/quotes-service.test.ts +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/server/quotes-service.ts src/lib/server/quotes-service.test.ts +git commit -m "feat(quotes): skip submitQuote unpriced gate when is_no_quote + +Lets a no-quote flag transition new → approval_pending even when +items have selling_price = 0. The PDF render path and email send +will relax the same way in subsequent commits. +" +``` + +--- + +## Task 5: Relax `diagnosePdfReadiness` pricing checks when `is_no_quote` (with test) + +**Files:** +- Modify: `src/lib/server/quotes-service.ts` (the `diagnosePdfReadiness` function — at ~line 1839-1862) +- Test: `src/lib/server/quotes-service.test.ts` (extend existing file) + +- [ ] **Step 1: Locate the function** + +```bash +grep -n "diagnosePdfReadiness\|items_not_ready" src/lib/server/quotes-service.ts +``` + +Read the function signature and body. Identify: +- What arguments it currently takes (likely a quote id, or already-loaded quote) +- Which line emits the `items_not_ready` failure code + +- [ ] **Step 2: Confirm the function signature already accepts the loaded quote** + +`diagnosePdfReadiness` is already defined as `async function diagnosePdfReadiness(pb, quoteId, quote: Quote)` — the loaded quote is the third argument, and the call site in `sendQuoteEmail` (`src/lib/server/quotes-service.ts:2009`) already passes the in-scope `quote` variable. No signature refactor needed — just use the `quote` parameter that's already there. + +- [ ] **Step 3: Write the failing test** + +Add to `quotes-service.test.ts`: + +```ts +describe("diagnosePdfReadiness with is_no_quote", () => { + it("returns no items_not_ready issue when is_no_quote and zero prices", async () => { + // setup: mock quote with is_no_quote: true, items with cost_price=0 and selling_price=0 + // call diagnosePdfReadiness(quoteId, loadedQuote) + // assert: no "items_not_ready" issue in the result + }); + + it("still flags no supplier when is_no_quote is true", async () => { + // setup: mock quote with is_no_quote: true, item with supplier field empty + // assert: "no supplier" appears in the issues + }); + + it("still flags items_not_ready when is_no_quote is false and zero prices", async () => { + // regression: behavior unchanged for priced quotes + }); +}); +``` + +Model these on any existing `diagnosePdfReadiness` tests in the file (search for it). + +- [ ] **Step 4: Run tests to verify they fail** + +```bash +npx vitest run src/lib/server/quotes-service.test.ts -t "diagnosePdfReadiness" +``` + +Expected: the new no-quote tests fail. + +- [ ] **Step 5: Modify the function** + +Find the per-item loop inside `diagnosePdfReadiness`. Add a guard: + +```ts +for (const it of allItems) { + const missing: string[] = []; + if (!quote.is_no_quote) { + if (!it.is_processed) missing.push("not processed"); + if (!(Number(it.cost_price) > 0)) missing.push("cost price"); + if (!(Number(it.selling_price) > 0)) missing.push("selling price"); + } + if (!it.supplier) missing.push("no supplier"); + // ...rest of the function unchanged +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +npx vitest run src/lib/server/quotes-service.test.ts -t "diagnosePdfReadiness" +``` + +Then run the full suite to catch any regressions in callers: + +```bash +npx vitest run src/lib/server/quotes-service.test.ts +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/server/quotes-service.ts src/lib/server/quotes-service.test.ts +git commit -m "feat(quotes): skip pricing checks in diagnosePdfReadiness when is_no_quote + +Keeps the 'no supplier' and 'not processed' checks active so the +function still flags legitimate data issues, but lets zero-priced +items through when the operator has marked the quote as a decline. +" +``` + +--- + +## Task 6: Verify `sendQuoteEmail` gate #6 is now relaxed via Task 5 + +**Files:** none — verification only. + +`sendQuoteEmail` calls `diagnosePdfReadiness(pb, quoteId, quote)` at line 2009, passing the in-scope loaded quote. Since Task 5 made `diagnosePdfReadiness` respect `quote.is_no_quote`, gate #6 naturally opens for no-quote letters. No code change required. + +- [ ] **Step 1: Read the gate #6 call site and confirm** + +```bash +sed -n '2000,2015p' src/lib/server/quotes-service.ts +``` + +Verify: +1. `sendQuoteEmail` calls `diagnosePdfReadiness` with the loaded `quote` (not a refetch). +2. The `quote.is_no_quote` boolean from Task 5's relaxation flows through. + +Both should be true. If either is not, fix the call site here (and add a commit). + +- [ ] **Step 2: Optional integration test** + +If a `sendQuoteEmail` test exists in `quotes-service.test.ts`, add a case asserting: +- A no-quote quote with no existing PDF returns `failure.code !== "items_not_ready"` (the readiness check returns OK because Task 5 relaxed it). +- A no-quote quote with an existing PDF proceeds to status flip. + +If the existing mock infrastructure can't easily support this, skip the unit test and rely on the manual smoke test in Task 11. + +- [ ] **Step 3: Commit (only if Step 2 added a test)** + +```bash +git add src/lib/server/quotes-service.test.ts +git commit -m "test(quotes): cover sendQuoteEmail gate #6 for no-quote path" +``` + +If no changes were made, skip this commit and move to Task 7. + +--- + +## Task 7: Bypass PDF render route's `ready` filter when `is_no_quote` + +**Files:** +- Modify: `src/app/api/v1/quotes/[id]/pdf/route.ts:198-217` +- Test: integration test (manual smoke test in Task 11 is acceptable; a unit test is optional) + +- [ ] **Step 1: Locate the route handler** + +```bash +grep -n "ready\|items_not_ready\|is_no_quote" src/app/api/v1/quotes/[id]/pdf/route.ts +``` + +Read the block that builds the `ready` array and the `if (ready.length === 0)` short-circuit. + +- [ ] **Step 2: Add the bypass** + +Read the quote header load near the top of the handler. The function should already load the quote (it does — the agent's report at line 159 confirms a placeholder-customer check on the loaded quote). After the quote load, add: + +```ts +const isNoQuote = !!loadedQuote.is_no_quote; +``` + +Then replace the `ready` build + empty-check block: + +```ts +// Original code (around line 198-217): +const ready = items.filter( + (it) => + Number(it.cost_price) > 0 && + Number(it.selling_price) > 0 && + !!it.supplier && + !!it.is_processed, +); +if (ready.length === 0) { + return NextResponse.json( + { code: "items_not_ready", ... }, + { status: 422 }, + ); +} +``` + +Becomes: + +```ts +const itemsForPdf = isNoQuote + ? items + : items.filter( + (it) => + Number(it.cost_price) > 0 && + Number(it.selling_price) > 0 && + !!it.supplier && + !!it.is_processed, + ); +if (!isNoQuote && itemsForPdf.length === 0) { + return NextResponse.json( + { code: "items_not_ready", ... }, + { status: 422 }, + ); +} +``` + +Then pass `noQuote: isNoQuote` into the `renderQuotePdf` call. Find the call (search for `renderQuotePdf(`) and add the prop to the props object: + +```ts +const pdfBuffer = await renderQuotePdf({ + quote: loadedQuote, + items: itemsForPdf, + customer, + letterhead, + logoDataUri, + partialNote, + quoteNotes, + noQuote: isNoQuote, // ← new +}); +``` + +- [ ] **Step 3: Hand-test the route** + +Manually: +1. Pick an existing quote with priced items. Confirm `GET /api/v1/quotes//pdf` still returns a PDF (regression check). +2. Flip `is_no_quote` to `true` via PB admin UI on the same quote. Confirm `GET /api/v1/quotes//pdf` returns a PDF — now with "Not quoted" cells. + +Verify the PDF visually: open the returned PDF, confirm: +- Item rows show "Not quoted" in price columns +- Totals show "—" +- If `quote_notes` is set, callout title is "Why we cannot quote" + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/v1/quotes/[id]/pdf/route.ts +git commit -m "feat(quotes): bypass PDF ready filter when is_no_quote + +Lets the PDF render with all active items (regardless of price) +when the operator has marked the quote as a decline letter. The +existing PDF diagnostic still flags data issues for priced quotes. +" +``` + +--- + +## Task 8: Add `noQuote` prop to `QuoteDocument` PDF component + +**Files:** +- Modify: `src/lib/pdf/quote-document.tsx:60` (props interface) and the rendering block + +- [ ] **Step 1: Add the prop to the interface** + +Read the file: + +```bash +grep -n "QuoteDocumentProps\|formatZAR\|partialNote\|quoteNotes" src/lib/pdf/quote-document.tsx | head -20 +``` + +Update the interface: + +```ts +export interface QuoteDocumentProps { + quote: Quote; + items: QuoteItem[]; + customer: Customer; + letterhead: "CNL" | "ISHERWOOD"; + logoDataUri?: string; + partialNote?: string; + quoteNotes?: string; + noQuote?: boolean; // ← new +} +``` + +- [ ] **Step 2: Read the prop inside the component** + +At the top of `QuoteDocument` function body, destructure `noQuote`: + +```ts +export function QuoteDocument({ + quote, + items, + customer, + letterhead, + logoDataUri, + partialNote, + quoteNotes, + noQuote, +}: QuoteDocumentProps) { + // ...rest of the function +} +``` + +- [ ] **Step 3: Update per-item price cells** + +Find the line that renders the item price column (the agent's report cites line 226-229): + +```tsx +{formatZAR(item.selling_price)} +``` + +Replace with: + +```tsx +{noQuote ? "Not quoted" : formatZAR(item.selling_price)} +``` + +And the line total cell: + +```tsx +{formatZAR(item.quantity * item.selling_price)} +``` + +Becomes: + +```tsx +{noQuote ? "Not quoted" : formatZAR(item.quantity * item.selling_price)} +``` + +- [ ] **Step 4: Update totals section** + +Find the totals rows (subtotal / VAT / total). The agent's report indicates currency formatting runs through `formatZAR`. Wrap each total in the `noQuote` conditional: + +```tsx +{noQuote ? "—" : formatZAR(subtotal)} +{noQuote ? "—" : formatZAR(vat)} +{noQuote ? "—" : formatZAR(total)} +``` + +Variable names will differ — find the actual local names with `grep -n "formatZAR\|subtotal\|total" src/lib/pdf/quote-document.tsx`. + +- [ ] **Step 5: Update the quote_notes callout header** + +Find the callout block that renders `quoteNotes`. Look for the string `"Note"` or similar. Update: + +```tsx +{quoteNotes && ( + + {noQuote ? "Why we cannot quote" : "Note"} + {quoteNotes} + +)} +``` + +- [ ] **Step 6: Manual smoke test** + +Re-run the manual test from Task 7 step 3 with `is_no_quote: true` set on a quote. Open the regenerated PDF and verify all three changes: +- Item cells show "Not quoted" +- Totals show "—" +- Callout title is "Why we cannot quote" (when `quote_notes` is set) + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/pdf/quote-document.tsx +git commit -m "feat(pdf): add noQuote prop to QuoteDocument + +Renders 'Not quoted' in item price cells, '—' in totals, and +changes the quote_notes callout header to 'Why we cannot quote' +when the flag is set. Visual identity stays the same (still a +QUOTATION document on letterhead). +" +``` + +--- + +## Task 9: Add the Switch toggle to the Quote Details card on the process page + +**Files:** +- Modify: `src/app/(app)/quotes/[id]/process/page.tsx` (~line 872) + +- [ ] **Step 1: Locate the Quote Details card** + +```bash +grep -n "Quote Details\|quote_date\|rfq_number" src/app/(app)/quotes/[id]/process/page.tsx | head -10 +``` + +Read the card structure. Identify where the existing fields (RFQ #, Created, Description) render. The toggle goes inside the card body, after the Description field. + +- [ ] **Step 2: Check existing imports** + +Confirm the file already imports `Switch` and `Label`: + +```bash +grep -n "from \"@/components/ui/switch\"\|from \"@/components/ui/label\"" src/app/(app)/quotes/[id]/process/page.tsx +``` + +If not, add: + +```tsx +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +``` + +- [ ] **Step 3: Add the handler** + +Find the existing state hooks for the quote. Add: + +```tsx +const [isNoQuote, setIsNoQuote] = useState(!!quote.is_no_quote); +const [noQuoteSaving, setNoQuoteSaving] = useState(false); + +const onToggleNoQuote = useCallback(async (next: boolean) => { + const previous = isNoQuote; + setIsNoQuote(next); // optimistic + setNoQuoteSaving(true); + try { + const res = await fetch(`/api/v1/quotes/${quote.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ is_no_quote: next }), + }); + if (!res.ok) { + setIsNoQuote(previous); // rollback + toast.error("Couldn't update — try again."); + } else if (next) { + // Scroll the quote_notes editor into view + document + .getElementById("quote-notes-card") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } catch { + setIsNoQuote(previous); + toast.error("Couldn't update — try again."); + } finally { + setNoQuoteSaving(false); + } +}, [isNoQuote, quote.id]); +``` + +If the file doesn't already use `toast`, add `import { toast } from "sonner";` (or whatever toast library the app uses — check neighboring files like `/quotes/[id]/page.tsx`). + +- [ ] **Step 4: Add the Switch JSX** + +Find the end of the Quote Details card content. Add after the Description row: + +```tsx +
+
+ +

+ Send this as an official decline. Prices will be ignored. +

+
+ +
+``` + +If the file doesn't already import `cn`, add `import { cn } from "@/lib/utils";`. + +- [ ] **Step 5: Verify the quote_notes card has the right id** + +Search for `ProcessQuoteNoteCard` import: + +```bash +grep -n "ProcessQuoteNoteCard\|process-quote-note-card" src/app/(app)/quotes/[id]/process/page.tsx +``` + +Find where the card is rendered and add `id="quote-notes-card"` to the outermost element: + +```tsx +
+ +
+``` + +If the component itself accepts an `id` prop, pass it through instead of wrapping. + +- [ ] **Step 6: Manual smoke test** + +1. Open the dev server. Navigate to `/quotes//process` in a browser. +2. Find the new "No-quote letter" switch in the Quote Details card. It should be off, and the card should have no amber tint. +3. Click the switch. Verify: + - The card border becomes amber + - The page scrolls to the quote_notes editor + - In the network tab, `PATCH /api/v1/quotes/` is called with `is_no_quote: true` +4. Refresh the page. Verify the switch is still on (state persisted). +5. Click the switch off. Verify it goes back to plain styling. +6. Refresh. Verify the switch is off (state cleared). + +- [ ] **Step 7: Commit** + +```bash +git add src/app/(app)/quotes/[id]/process/page.tsx +git commit -m "feat(quotes): add no-quote toggle to Quote Details card + +Switch + Label inside the Quote Details card on the process page. +Optimistic update with rollback on error. When flipped on, scrolls +the quote_notes editor into view so the operator can type the +reason in the same flow. +" +``` + +--- + +## Task 10: Expose `is_no_quote` on the MCP tools + +**Files:** +- Modify: `src/app/api/mcp/route.ts` (the `quotes.create` and `quotes.update` tool input shapes) + +- [ ] **Step 1: Locate the tool definitions** + +```bash +grep -n "quotes.create\|quotes.update\|is_template\|quote_notes" src/app/api/mcp/route.ts | head -30 +``` + +The existing in-progress work (the three modified files per `git status`) is adding `status`, `rfq_number`, `quote_number`, `notes`, and `quote_notes` to the `quotes.create` tool. The `is_no_quote` field goes alongside them. + +- [ ] **Step 2: Add to `quotes.create`** + +Find the `quotes.create` tool input shape. Add the field to the properties block and a hint string: + +```ts +is_no_quote: { + type: "boolean", + description: + "When true, the quote is sent as an official decline letter; " + + "prices may be zero and PDF/submit gates skip the pricing check. " + + "Reversible — pass false to re-enable normal pricing flow.", +}, +``` + +If the tool has a separate `hint` field on each property (look at how `quote_notes` is hinted), mirror that pattern. + +- [ ] **Step 3: Add to `quotes.update` patch whitelist** + +Find the `quotes.update` patch whitelist. Add `is_no_quote` to the list of patchable fields with the same hint. + +- [ ] **Step 4: Pass through to the service layer** + +In the routing code for `quotes.create` and `quotes.update`, ensure the new field is forwarded to the underlying service call. Look at how `quote_notes` is forwarded and copy that pattern. + +- [ ] **Step 5: Verify with an MCP smoke call** + +If MCP smoke tests exist (check `src/app/api/mcp/route.test.ts` or similar), add a test case. Otherwise, manually invoke the tool: + +```bash +curl -X POST http://localhost:4001/api/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $AGENT_API_KEY" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quotes.create","arguments":{"customer":"","items":[{"description":"Test"}],"is_no_quote":true}}}' +``` + +(Replace the customer id with a real one. Or use `quotes.update` against an existing quote to test the update path.) + +Verify the response contains `is_no_quote: true`. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/api/mcp/route.ts +git commit -m "feat(mcp): expose is_no_quote on quotes.create and quotes.update + +Lets Hermes and other external agents drive the no-quote flow without +a UI step. Hints document the gate-bypass and reversibility. +" +``` + +--- + +## Task 11: End-to-end smoke test + +**Files:** none — manual verification only. + +- [ ] **Step 1: Pick a fresh quote** + +Pick an existing quote with at least one line item, ideally one that hasn't been sent yet. Note its quote number. + +- [ ] **Step 2: Walk the full no-quote flow** + +1. Open the quote's process page. The Quote Details card shows the new toggle. +2. Click the toggle on. The card border becomes amber; the page scrolls to the quote_notes editor. +3. Type a reason into the quote_notes editor: e.g., "Unfortunately these items fall outside our current supply scope. We appreciate the opportunity to quote." +4. Save the quote_notes (use the existing editor save — this hits the same PATCH endpoint). +5. Submit the quote for approval (normal flow — uses the relaxed `submitQuote` gate). Verify it transitions to `approval_pending` without errors. +6. Approve the quote (use the existing approval flow). Verify it transitions to `approved`. +7. Send the quote. Verify the email sends without errors. +8. Open the PDF attachment. Verify: + - Items render with "Not quoted" in price columns + - Totals show "—" + - Callout header reads "Why we cannot quote" + - Item descriptions and quantities are unchanged +9. Open the customer-side email. Verify it has the PDF attached, the subject line is the same as a normal quote reply, and the body is the operator's quote_notes. + +- [ ] **Step 3: Walk the reversal flow** + +1. Go back to the process page. Toggle off the no-quote switch. +2. Verify the card border returns to plain. +3. Refresh the page. Verify the switch stays off (persisted). +4. Re-price the items (just enter a value into one line's selling price). Verify the normal flow resumes. +5. Toggle back on. Verify pricing is still visible in the editor but the "Not quoted" badge would appear on the next PDF regen. + +- [ ] **Step 4: Document any issues found** + +If any step fails, capture the failure mode and link to it in a follow-up commit. Do NOT mark the plan complete if any step fails — fix the underlying code, re-run from the failing step. + +- [ ] **Step 5: Final commit (if any smoke-test fixes were needed)** + +```bash +git add -A +git commit -m "fix(quotes): address smoke-test issues from no-quote rollout" +``` + +If no fixes were needed, no commit is required. + +--- + +## Self-review checklist + +Before considering the plan complete, the engineer (or the planner, before handing off) should verify: + +- [ ] Each gate in the spec maps to a task (submit → 4, diagnose-pdf-readiness → 5, pdf route ready filter → 7, send-email gate → 6). +- [ ] All four file:line citations from the spec map to tasks (types.ts → 2; quotes-service.ts → 4, 5, 6; pdf/route.ts → 7; quote-document.tsx → 8; process page → 9; mcp/route.ts → 10). +- [ ] Tests are written BEFORE implementation in every TDD task (3, 4, 5). +- [ ] Manual smoke tests are explicit in 7, 8, 9, 11. +- [ ] Every commit uses Conventional Commits format with a `Co-Authored-By: Claude ` trailer. +- [ ] No "TBD" / "TODO" / "fill in" placeholders in any task.