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. +

+
+ +
+``` + +The amber border only renders when the toggle is on. When off, the row is plain. + +When flipped on, scroll the existing `ProcessQuoteNoteCard` (`quote_notes` editor) into view via `scrollIntoView({ behavior: "smooth" })` — the natural follow-up is "type the reason." + +### Behaviour + +- Optimistic update on toggle change; rollback + toast on PATCH failure. +- Persistence: `PATCH /api/v1/quotes/[id]` with `{ is_no_quote: boolean }`. + +No other UI changes in v1. No badge in the quotes list, no banner on the detail header. Operators who want a visual indicator open the process page. + +--- + +## API surface + +### REST + +`PATCH /api/v1/quotes/[id]` accepts `is_no_quote` in the body, gated by `QUOTE_PATCHABLE_FIELDS`. + +### MCP + +`src/app/api/mcp/route.ts`: + +- `quotes.create` — accepts `is_no_quote?: boolean` (hint: "When true, the quote is sent as an official decline letter; prices may be zero and PDF/submit gates skip the pricing check.") +- `quotes.update` — accepts `is_no_quote?: boolean` in the patch (hint: "Toggle the no-quote flag. Reversible: false → off re-enables normal pricing flow.") + +--- + +## Error handling + +| Failure mode | Behaviour | +|---|---| +| PATCH toggle flip fails | Optimistic update rolls back; toast `"Couldn't update — try again."` Toggle returns to previous state | +| No-quote with zero items | Submit still fails with existing `no_items` gate. Not relaxed — a no-quote with no items is meaningless | +| Toggle flipped off after submitting | Allowed. Decoupled from status. Operator can re-price and re-submit | +| Toggle flipped on after sending | Allowed. Existing PDF re-render path handles regen | +| Concurrent edits | PB last-write-wins. No special handling | +| Submit on no-quote with no `quote_notes` | Allowed. PDF renders items with "Not quoted" cells but no reason callout. We do not force a reason | +| Send on no-quote before PDF exists | Works — send triggers inline PDF render, which now passes relaxed `diagnosePdfReadiness` | +| `is_no_quote` AND `is_template` both true | Mutually exclusive at the UX level (toggle is on process page, template action is in kebab). Schema doesn't enforce mutual exclusion; rare edge case is operator-resolved | + +--- + +## Tests + +Service layer (`quotes-service.test.ts` or new spec file): + +1. `submitQuote` accepts zero-priced items when `is_no_quote` true +2. `submitQuote` still rejects zero-priced items when `is_no_quote` false (regression) +3. `submitQuote` accepts zero-priced items when `is_no_quote` true AND `partial: true` +4. `diagnosePdfReadiness` returns no `items_not_ready` when `is_no_quote` true with zero prices +5. `diagnosePdfReadiness` still flags `no supplier` when `is_no_quote` true +6. `submitQuote` with `is_no_quote` true but zero items throws `no_items` + +PDF route: + +7. GET PDF on no-quote quote returns 200 with `"Not quoted"` text in body +8. GET PDF on no-quote quote with empty `quote_items` returns 200 with callout-only layout +9. GET PDF on no-quote quote with `quote_notes` set renders callout header as `"Why we cannot quote"` + +MCP: + +10. `quotes.create` with `is_no_quote: true` persists flag and returns quote +11. `quotes.update` with patch `{ is_no_quote: false }` clears flag +12. `quotes.update` rejects unknown fields (regression) + +Component: + +13. Quote Details card with `is_no_quote: false` → switch unchecked, no amber tint +14. Quote Details card with `is_no_quote: true` → switch checked, amber border visible +15. Clicking the switch fires PATCH and updates local state + +--- + +## Rollout + +Each step is independently deployable. Steps 1–2 land first (schema + service gate relaxation) so the migration is reversible before any user-facing change. + +1. **Schema** — add `is_no_quote` boolean to `quotes` via PB admin UI (default `false`). Update `pb-exports/pb_schema-2.json`. +2. **Types + service gates** — add to `Quote`, relax the four gates, add service-layer tests. +3. **PDF component + route** — add `noQuote` prop, render path, "Not quoted" cells. Manual smoke test. +4. **UI toggle** — add Switch to Quote Details card, wire up PATCH, add component tests. +5. **MCP tools** — expose `is_no_quote` on `quotes.create` and `quotes.update`. +6. **End-to-end smoke test** — create a quote, flip toggle on, type reason, submit, generate PDF, send. Verify PDF layout and email contents. + +--- + +## Out of scope (deferred) + +These were discussed during brainstorming and explicitly deferred to keep v1 minimal: + +- "No quote" badge in the quotes list rows (approach B) +- Confirmation prompt inside the send-quote modal (approach B) +- Amber explanation banner on the Quote Details card when toggle is on (approach B, partly included as the inline border) +- Structured audit-trail entries on toggle flip (approach C) +- Canned reasons dropdown (rejected in Q3 in favour of free-text reuse of `quote_notes`) +- Dedicated `no_quote_reason` field (rejected in Q3 in favour of `quote_notes` reuse) +- New `no_quote` status (rejected in Q1 in favour of boolean flag for reversibility) +- Mutual exclusion constraint between `is_no_quote` and `is_template` + +Each deferred item is a clean follow-up commit if operator feedback calls for it. + +--- + +## Open questions + +None at design time. All design-shaping questions were resolved during brainstorming.