initial commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# 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' | 'declined'` | The actual customer-facing status |
|
||||
|
||||
**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`
|
||||
|
||||
```typescript
|
||||
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.
|
||||
Reference in New Issue
Block a user