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

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, sets customerDecision to 'pending' (when unapproving) or 'approved' (when approving)
  • toggleDeclined (X checkbox click): sets approved: false, toggles customerDecision between '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 state
  • toggleDeclined(id) — toggles decline (X) checkbox state
  • updateService(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.