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

2.6 KiB

QuoteService dual-field trap: approved vs customerDecision

The QuoteService type has TWO fields that represent approval state:

export interface QuoteService extends Service {
  approved: boolean;
  customerDecision: 'approved' | 'declined' | 'pending';
  // ...
}

These MUST stay in sync. When they diverge, the UI summary and the PDF produce different totals.

The invariant

Every code path that sets one MUST set the other consistently:

State approved customerDecision
Customer approved true 'approved'
Customer declined false 'declined'
Pending decision false 'pending'

Where each filter is used

ALL totals (src/lib/totals.ts, src/store/quote.ts, src/lib/pdf.ts)

All three now use customerDecision !== 'declined' — both approved AND pending services count. Only declined services are excluded from subtotal, discount base, shop charge, and tax. Both the on-screen Quote Summary sidebar AND the generated PDF/Print use the same filter.

UI grouping (src/pages/QuoteGenerator.tsx ServicesTable)

Three sections by customerDecision: Approved (green), Pending (gray), Declined (red).

Three-state toggle system

Two store actions handle the three states, both keeping fields in sync:

toggleApproved(id) — toggles between approved ↔ pending:

  • If approved → { approved: false, customerDecision: 'pending' }
  • If not approved → { approved: true, customerDecision: 'approved' }

toggleDeclined(id) — toggles between declined ↔ pending:

  • If declined → { approved: false, customerDecision: 'pending' }
  • If not declined → { approved: false, customerDecision: 'declined' }

Both are wired to adjacent checkboxes in the ServiceRow: green ✓ (toggleApproved) and red ✕ (toggleDeclined).

The expanded Customer Decision buttons (Approve / Decline / Pending) are the alternative UI path — they use onUpdate() to set both fields atomically.

Pitfall: all three must use the same filter

All three places that compute totals must use the SAME filter — customerDecision !== 'declined':

  • computeQuoteTotals() in src/lib/totals.ts
  • Store subtotal() / approvedSubtotal() in src/store/quote.ts
  • PDF servicesTotal + shop charge in src/lib/pdf.ts

If any one of these diverges, the on-screen summary and the generated PDF will show different amounts, or the PDF will show $0.00 when all services are pending.

Prevention: when changing the filter rule, grep all three files and change them together.