4.0 KiB
Customer Decision System (v2 Quote Generator)
Fields
Each QuoteService has two related fields:
| Field | Type | Purpose |
|---|---|---|
approved |
boolean | Visual toggle state for the green checkbox |
customerDecision |
'approved' / 'pending' / 'declined' |
Actual decision state |
How They Stay in Sync
- Approve button (green ✓): sets
approved: true, customerDecision: 'approved' - Decline button (red ✕): sets
approved: false, customerDecision: 'declined' - Pending button: sets
approved: false, customerDecision: 'pending' - toggleApproved (checkbox click): toggles
approved, setscustomerDecisionto'pending'(when unapproving) or'approved'(when approving) - toggleDeclined (X checkbox click): sets
approved: false, togglescustomerDecisionbetween'declined'and'pending'
Both are set together in every action path — they never drift.
Service Grouping in ServicesTable
The ServicesTable component groups services by customerDecision into three sections:
- Approved (green header):
customerDecision === 'approved' - Pending (gray header):
customerDecision === 'pending' - Declined (red header):
customerDecision === 'declined'
An "Approve All" button sits above the groups when non-approved services exist.
Totals Calculation: billableServices()
Defined in src/lib/totals.ts. This is the single shared heuristic used by the UI Quote Summary, the store's subtotal()/approvedSubtotal(), and the PDF generator.
export function billableServices(services: { customerDecision: string }[]) {
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') // mixed → only approved
: nonDeclined; // unanimous → all
}
Rules:
- All services approved → count all (full total)
- All services pending → count all (full total)
- Mixed approved + pending → count only approved (partial total)
- Declined services → always excluded
This makes totals dynamic: they adjust as the advisor approves/declines individual services. The full total only appears when the decision state is unanimous.
PDF Generation
In src/lib/pdf.ts, the billableServices() result is pre-computed as a Set of service IDs:
const billableIds = new Set(billableServices(services).map(s => s.id));
Used in two places within the generator:
- Service total accumulation:
if (billableIds.has(svc.id)) servicesTotal += price - Shop charge base:
(billableIds.has(s.id) && s.applyShopCharge)
Store Actions
toggleApproved(id)— toggles approve checkbox statetoggleDeclined(id)— toggles decline (X) checkbox stateupdateService(id, updates)— partial update (used by Approve/Decline/Pending buttons)
Dual Checkbox UI
ServiceRow renders two checkboxes side by side:
| Checkbox | Icon | Active color | Calls |
|---|---|---|---|
| Approve | ✓ (always visible, gray when off) | Green | onToggleApproved |
| Decline | ✕ (always visible, gray when off) | Red | onToggleDeclined |
The icons are always rendered — grayed-out (text-gray-300) when inactive, white on colored background when active. Clicking either toggles its state.
Recent Quotes Status Display
The Recent Quotes panel (src/pages/QuoteGenerator.tsx) derives a display status from service decisions, NOT from the raw PocketBase status field. When all services are decided (no pending), the status is computed: all-approved → "Approved", all-declined → "Declined". The PocketBase status field only tracks lifecycle ('draft' on save, 'sent' on share, 'approved'/'declined' on customer submission via QuoteApprove) — it does NOT reflect service-level decisions made in the QuoteGenerator.
See references/recent-quotes-status-derivation.md for the full fix and rationale.