docs(quotes): add no-quote feature design spec

Adds is_no_quote boolean to quotes collection so operators can send
an official decline letter on a quote the company cannot price.
Spec covers schema, service-layer gate relaxations, PDF rendering,
UI toggle placement, error edges, tests, and rollout.

Approach A (surgical flag relaxation) chosen over B/C during
brainstorming to keep v1 minimal and reversible.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-26 23:08:08 +02:00
parent e900772faa
commit 670ed791ec

View file

@ -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
<div className="flex items-center justify-between rounded-md border border-amber-200 bg-amber-50 p-3">
<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={!!quote.is_no_quote}
onCheckedChange={onToggleNoQuote}
size="sm"
/>
</div>
```
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.