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

86 lines
4.0 KiB
Markdown

# 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.
```typescript
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:
```typescript
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.