Files
2026-07-12 10:17:17 -04:00

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

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.