WFDASH-WEBAPP/docs/superpowers/plans/2026-08-26-no-quote.md
Dave c3e9c74d61 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 <noreply@anthropic.com>
2026-08-26 23:13:02 +02:00

35 KiB

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

{
  "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
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

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

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:

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

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:

import { setQuoteNotes, setIsNoQuote } from "@/lib/server/quotes-service";

Add a validation block after the existing quote_notes type check (around line 70):

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:

try {
  let quote: Awaited<ReturnType<typeof setQuoteNotes>> | 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:

/**
 * 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<Quote> {
  return pb.collection("quotes").update<Quote>(quoteId, { is_no_quote: value });
}
  • Step 4: Run tests to verify they pass
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
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
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
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
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:

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

npx vitest run src/lib/server/quotes-service.test.ts
  • Step 7: Commit
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

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:

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

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
npx vitest run src/lib/server/quotes-service.test.ts -t "diagnosePdfReadiness"

Then run the full suite to catch any regressions in callers:

npx vitest run src/lib/server/quotes-service.test.ts
  • Step 7: Commit
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
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)
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

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:

const isNoQuote = !!loadedQuote.is_no_quote;

Then replace the ready build + empty-check block:

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

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:

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/<id>/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/<id>/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

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:

grep -n "QuoteDocumentProps\|formatZAR\|partialNote\|quoteNotes" src/lib/pdf/quote-document.tsx | head -20

Update the interface:

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:

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

<Text>{formatZAR(item.selling_price)}</Text>

Replace with:

<Text>{noQuote ? "Not quoted" : formatZAR(item.selling_price)}</Text>

And the line total cell:

<Text>{formatZAR(item.quantity * item.selling_price)}</Text>

Becomes:

<Text>{noQuote ? "Not quoted" : formatZAR(item.quantity * item.selling_price)}</Text>
  • 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:

<Text>{noQuote ? "—" : formatZAR(subtotal)}</Text>
<Text>{noQuote ? "—" : formatZAR(vat)}</Text>
<Text>{noQuote ? "—" : formatZAR(total)}</Text>

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:

{quoteNotes && (
  <View style={...}>
    <Text>{noQuote ? "Why we cannot quote" : "Note"}</Text>
    <Text>{quoteNotes}</Text>
  </View>
)}
  • 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

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

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:

grep -n "from \"@/components/ui/switch\"\|from \"@/components/ui/label\"" src/app/(app)/quotes/[id]/process/page.tsx

If not, add:

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:

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:

<div
  className={cn(
    "mt-3 flex items-center justify-between rounded-md border p-3",
    isNoQuote
      ? "border-amber-200 bg-amber-50"
      : "border-transparent bg-transparent",
  )}
>
  <div className="space-y-0.5">
    <Label htmlFor="is_no_quote" className="text-sm font-medium">
      No-quote letter
    </Label>
    <p className="text-xs text-muted-foreground">
      Send this as an official decline. Prices will be ignored.
    </p>
  </div>
  <Switch
    id="is_no_quote"
    checked={isNoQuote}
    onCheckedChange={onToggleNoQuote}
    disabled={noQuoteSaving}
    size="sm"
  />
</div>

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:

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:

<div id="quote-notes-card">
  <ProcessQuoteNoteCard ... />
</div>

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/<some-quote-id>/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/<id> 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
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

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:

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:

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":"<customer-id>","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
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)
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 <noreply@anthropic.com> trailer.
  • No "TBD" / "TODO" / "fill in" placeholders in any task.