# Quote Generator — Totals Architecture Path: `src/pages/QuoteGenerator.tsx`, `src/lib/totals.ts`, `src/lib/pdf.ts`, `src/store/quote.ts` ## Core Concept: `approved` vs `customerDecision` Each `QuoteService` has two decision fields that MUST stay in sync: | Field | Type | Meaning | |-------|------|---------| | `approved` | `boolean` | UI checkbox state (is the service included in the quote?) | | `customerDecision` | `'approved' | 'pending' | 'declined'` | The actual customer-facing status | **Critical invariant:** `approved` and `customerDecision` must agree. When a service is `customerDecision: 'pending'`, `approved` must be `false`. When `customerDecision: 'declined'`, `approved` must be `false`. Only `customerDecision: 'approved'` should have `approved: true`. All mutation paths enforce this: - `toggleApproved()` — sets both fields atomically - `toggleDeclined()` — sets both fields atomically - Customer Decision buttons (Approve/Decline/Pending) — all three set both fields - New service via `addService()` — initializes both to pending state (`approved: false, customerDecision: 'pending'`) ## `billableServices()` Helper Location: `src/lib/totals.ts` ```typescript export function billableServices(services) { const nonDeclined = services.filter(s => s.customerDecision !== 'declined'); const hasApproved = nonDeclined.some(s => s.customerDecision === 'approved'); const hasPending = nonDeclined.some(s => s.customerDecision === 'pending'); return (hasApproved && hasPending) ? nonDeclined.filter(s => s.customerDecision === 'approved') : nonDeclined; } ``` **Heuristic:** - All services pending → count all (full total) - All services approved → count all (full total) - Mixed approved + pending → count only approved (partial total) - Declined services → always excluded This is the shared single source of truth used by: 1. `computeQuoteTotals()` — UI Quote Summary sidebar 2. Store `subtotal()` / `approvedSubtotal()` — computed getters 3. PDF generator — `servicesTotal` accumulation and shop charge calculation ## Where Totals Are Calculated ### 1. UI Summary (`QuoteSummary` component) - Uses `computeQuoteTotals({ services, discount, settings })` from `src/lib/totals.ts` - Shows Subtotal, Discount, Shop Charge, Tax, TOTAL in the right sidebar - Renders only if settings have non-zero rates ### 2. Store Computed Getters - `subtotal()` and `approvedSubtotal()` in `src/store/quote.ts` - Both use `billableServices()` for consistency - Used by `approvedSubtotal` display in `hasMixed` check ### 3. PDF Generator (`generateQuotePDF` in `src/lib/pdf.ts`) - Pre-computes `billableIds = new Set(billableServices(services).map(s => s.id))` - Service accumulation: `if (billableIds.has(svc.id)) servicesTotal += price` - Shop charge: `billableIds.has(s.id) && s.applyShopCharge` - Discount, tax, and total are computed from `servicesTotal` ## ServiceRow Checkboxes Two mutually-exclusive checkboxes in the collapsed row: | Checkbox | Icon | Active colors | Inactive colors | Action | |----------|------|---------------|-----------------|--------| | Approve | ✓ checkmark | green bg, white icon | gray border, gray icon | `toggleApproved()` | | Decline | ✕ X mark | red bg, white icon | gray border, gray icon | `toggleDeclined()` | Both icons are ALWAYS visible — grayed out when inactive, white when active on colored background. This gives the user a visual cue of what clicking will do. ## ServicesTable Groups Three groups sorted by `customerDecision` (not `approved` boolean): 1. **Approved** (green header) — `customerDecision === 'approved'` 2. **Pending** (gray header) — `customerDecision === 'pending'` 3. **Declined** (red header) — `customerDecision === 'declined'` ## "Approve All" Button A green button above the service groups, only visible when `notApprovedCount > 0`. Calls `updateService` on each non-approved service to set `approved: true, customerDecision: 'approved'`. ## Common Pitfalls - **Don't filter by `s.approved` for totals.** Always use `billableServices()` or `customerDecision !== 'declined'` / `billableIds.has()`. The `approved` boolean is a UI convenience, not the source of truth for billing. - **Don't let `approved` and `customerDecision` diverge.** Every mutation path must set both fields together. Adding a new path that sets only one will cause the UI summary and PDF to disagree. - **PDF has its own totals math** inline (discount/shop charge/tax/total) that must stay consistent with `billableServices`. Any change to the heuristic must update both `totals.ts` and `pdf.ts`. - **Changing service defaults? Hit all three add paths.** `QuoteGenerator.tsx` adds services in three places — all must set `approved` + `customerDecision` consistently: (1) `handleAdd` (catalog picker, ~line 147), (2) RO import `forEach` (~line 772), (3) `handleAddSuggestion` (AI suggestions, ~line 952). Missing one creates inconsistent behavior depending on how the service was added.