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.