initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -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.