4.9 KiB
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' |
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 atomicallytoggleDeclined()— 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
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:
computeQuoteTotals()— UI Quote Summary sidebar- Store
subtotal()/approvedSubtotal()— computed getters - PDF generator —
servicesTotalaccumulation and shop charge calculation
Where Totals Are Calculated
1. UI Summary (QuoteSummary component)
- Uses
computeQuoteTotals({ services, discount, settings })fromsrc/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()andapprovedSubtotal()insrc/store/quote.ts- Both use
billableServices()for consistency - Used by
approvedSubtotaldisplay inhasMixedcheck
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):
- Approved (green header) —
customerDecision === 'approved' - Pending (gray header) —
customerDecision === 'pending' - 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.approvedfor totals. Always usebillableServices()orcustomerDecision !== 'declined'/billableIds.has(). Theapprovedboolean is a UI convenience, not the source of truth for billing. - Don't let
approvedandcustomerDecisiondiverge. 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 bothtotals.tsandpdf.ts. - Changing service defaults? Hit all three add paths.
QuoteGenerator.tsxadds services in three places — all must setapproved+customerDecisionconsistently: (1)handleAdd(catalog picker, ~line 147), (2) RO importforEach(~line 772), (3)handleAddSuggestion(AI suggestions, ~line 952). Missing one creates inconsistent behavior depending on how the service was added.