diff --git a/docs/superpowers/specs/2026-08-26-no-quote-design.md b/docs/superpowers/specs/2026-08-26-no-quote-design.md new file mode 100644 index 0000000..6481eac --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-no-quote-design.md @@ -0,0 +1,242 @@ +# No-quote feature — design spec + +**Status:** Approved (brainstorming complete, awaiting writing-plans) +**Date:** 2026-08-26 +**Owner:** Dave +**Approach:** A — Surgical flag relaxation (minimal surface) + +--- + +## Summary + +Add the ability for an operator to send an official *decline letter* on a quote — a quote that the company cannot price. The customer receives a properly-formatted letter on the existing letterhead, listing the items they enquired about (marked "Not quoted") with a free-text reason explaining why we cannot quote. + +Today, zero-priced items block submission, PDF generation, and email send. We add a single boolean `is_no_quote` to the `quotes` collection. When the flag is true, the four pricing gates relax, the PDF renders a "Not quoted" layout, and the customer-facing flow works end-to-end. + +--- + +## Motivation + +The current behaviour when an enquiry falls outside our scope is to leave the customer without a response, which damages the relationship. We want a professional, on-letterhead "we cannot quote" reply that: + +- Acknowledges the customer's RFQ (so they know we received it) +- Lists the items they asked about (so they know what was being requested) +- Explains why we can't quote (free-text reason) +- Is visually consistent with the rest of our customer correspondence (same letterhead, same PDF generation pipeline) + +--- + +## Decisions log + +Resolved during brainstorming on 2026-08-26: + +| Question | Decision | +|---|---| +| What does the customer see on the no-quote PDF? | Items shown, each marked "Not quoted" | +| Where does the toggle live in the UI? | Inside the Quote Details card on the process page | +| How is the reason captured? | Reuse the existing `quote_notes` field (already renders on the PDF as a callout) | +| Can the operator change their mind after flipping the toggle on? | Yes — the toggle is reversible at any time | +| What status path does a sent no-quote follow? | Same as a priced quote: `new → approval_pending → sending → emailed` | + +--- + +## Data model + +### Schema change + +Add to the `quotes` collection (PB admin UI): + +``` +is_no_quote: bool (default false) +``` + +This mirrors the existing `is_template` boolean exactly. Same field shape, same default, same lifecycle. The PB admin must be updated via the UI per `AGENTS.md` (source of truth is the running PB instance); the local snapshot at `pb-exports/pb_schema-2.json` is updated for reference. + +### TypeScript + +`src/lib/types.ts` — `Quote` interface (~line 247) gains: + +```ts +is_no_quote?: boolean; +``` + +`src/lib/pdf/quote-document.tsx` — `QuoteDocumentProps` interface (line 60) gains: + +```ts +noQuote?: boolean; +``` + +### Whitelists + +- `QUOTE_PATCHABLE_FIELDS` in `src/lib/server/quotes-service.ts` (~line 2676) — add `"is_no_quote"`. +- `quotes.create` and `quotes.update` MCP tool input shapes — add `is_no_quote?: boolean`. + +--- + +## Service-layer gate relaxations + +Four gates currently block a quote with zero-priced items. Each becomes a one-line bypass when `quote.is_no_quote === true`: + +| Gate | File:Line | Relaxation | +|---|---|---| +| `submitQuote` unpriced-items check | `src/lib/server/quotes-service.ts:2519–2542` | Skip when `quote.is_no_quote` | +| `diagnosePdfReadiness` per-item pricing checks | `src/lib/server/quotes-service.ts:1839–1862` | Skip `cost price` and `selling price` checks (keep `no supplier` and `not processed`) | +| PDF render route `ready.length === 0` | `src/app/api/v1/quotes/[id]/pdf/route.ts:198–217` | Skip filter and empty-check; pass all active items through | +| `sendQuoteEmail` gate #6 (chains to `diagnosePdfReadiness`) | `src/lib/server/quotes-service.ts:2002` | Already covered by relaxing `diagnosePdfReadiness` | + +The existing `partial: true` submit path (commit `cf87a37`) is untouched — it's a separate concern (in-progress quote waiting on supplier prices). + +--- + +## PDF rendering + +`src/lib/pdf/quote-document.tsx`: + +- New `noQuote?: boolean` prop on `QuoteDocumentProps`. +- Per-item row, when `noQuote`: + - Selling-price cell renders `"Not quoted"` instead of `formatZAR(item.selling_price)`. + - Line-total cell renders `"Not quoted"` instead of `formatZAR(quantity * selling_price)`. +- Totals section, when `noQuote`: subtotal / VAT / total cells render `"—"`. Currency formatting only runs when `!noQuote`. +- The "QUOTATION" title stays unchanged (a decline letter is still a quote that chooses not to price; same brand identity). +- The `quote_notes` callout header changes from `"Note"` to `"Why we cannot quote"` when `noQuote && quoteNotes`. The callout component itself is unchanged; only the title string is conditional. + +`src/app/api/v1/quotes/[id]/pdf/route.ts`: + +- After loading the quote header, read `quote.is_no_quote` into a local. +- When `noQuote`, skip the `items.filter(cost_price > 0 && selling_price > 0 && supplier && is_processed)` step — load all active items. +- Pass `noQuote: quote.is_no_quote` into `renderQuotePdf`. + +--- + +## UI + +### Toggle on Quote Details card + +`src/app/(app)/quotes/[id]/process/page.tsx` — inside the Quote Details card (around line 872, after the existing Description field): + +```tsx +
+ Send this as an official decline. Prices will be ignored. +
+