# v2 Quote Generator & PDF Architecture
## Service Flags → PDF Dynamic Rendering
Every service in the QuoteGenerator carries flags that render in the PDF. All typed in `src/types.ts` under `QuoteService`:
| Flag | Type | PDF Output |
|------|------|------------|
| `partsNotInStock` | boolean | "NOTE: Parts for this service are not in stock and will need to be ordered." |
| `aftermarketAvailable` | boolean | "NOTE: Aftermarket parts are available for this service." |
| `partsDeliveryTime` | string? | "Estimated Delivery: {value}" (shown when partsNotInStock) |
| `aftermarketPartsList` | string? | "Aftermarket Options: {value}" (shown when aftermarketAvailable) |
| `noPartsRequired` | boolean | Shown in UI only as "Labor Only" — NOT rendered in PDF |
| `recommendationReason` | string? | Rendered as UPPERCASE before explanation in PDF |
## PDF Layout (exact legacy match)
The PDF has these sections in order:
1. Header box (light gray bg, two-column: SERVICE PROVIDER left, CUSTOMER INFORMATION right)
2. Personalized message (template vars `{customerName}`, `{vehicleInfo}`)
3. Services section — split into APPROVED / POSTPONED if mixed decisions
4. Priority headers with icons ([CRITICAL], [SAFETY], etc.) and reasons
5. Customer decision badges (✓ CUSTOMER APPROVED green, ✗ CUSTOMER DECLINED red with strikethrough)
6. **Parts status flags** rendered inline per service (see table above)
7. Pricing summary box (subtotal, approved, discount, shop charge, tax, TOTAL)
8. Shop charge explanation footnote
9. Footer (thank-you message, quote validity note, page numbers)
## Key Files
| File | Role |
|------|------|
| `src/types.ts` | `QuoteService`, `CustomerInfo`, `ShopSettings` types |
| `src/store/quote.ts` | Zustand store with persist middleware |
| `src/lib/pdf.ts` | `generateQuotePDF()`, `downloadQuotePDF()`, `printQuotePDF()` |
| `src/pages/QuoteGenerator.tsx` | Full page: CustomerInfoPanel, ServiceSearch, ServicesTable, QuoteSummary |
| `src/lib/ai.ts` | `getPriorityAnalysis()`, `aiWriteExplanation()`, `aiSuggestServices()` |
## Legacy-to-v2 mapping
| Legacy (quote-tab-manager.js) | v2 React |
|------|------|
| `generateQuotePDF()` lines 3755-4408 | `src/lib/pdf.ts` `generateQuotePDF()` |
| `renderSelectedServices()` | `ServicesTable` + `ServiceRow` components |
| `togglePartsStatus()` | inline `onUpdate()` calls with flags |
| `editAftermarketParts()` | inline `` in ServiceRow expanded section |
| `editPartsDeliveryTime()` | inline `` in ServiceRow expanded section |
| `toggleShopCharge()` | checkbox in ServiceRow |
| `setCustomerDecision()` | button row in ServiceRow (Approve/Decline/Pending) |
## Pitfall: reset() ordering on load
When loading a saved quote via `?edit=`, call `reset()` FIRST, then `setCustomerInfo()`. The old code called `setCustomerInfo()` before `reset()`, which immediately wiped customer data back to empty.
```tsx
// RIGHT:
reset(); // clear everything first
setCustomerInfo(data.customerInfo); // then populate
data.services?.forEach(s => addService(s));
// WRONG (v2 bug until 2026-06-28):
setCustomerInfo(data.customerInfo); // populated
reset(); // WIPES to empty!
```
## Pitfall: PocketBase JSON field guard (v2)
PocketBase may return nested JSON fields (`customerInfo`, `services`, `discount`) as strings depending on the field type in the collection schema (text vs json). Always apply the three-step guard when loading quote data:
```tsx
const ci = typeof data.customerInfo === 'string'
? JSON.parse(data.customerInfo)
: data.customerInfo;
if (ci && typeof ci === 'object') setCustomerInfo(ci);
const svcs = typeof data.services === 'string'
? JSON.parse(data.services)
: data.services;
svcs.forEach((s: QuoteService) => addService(s));
const d = typeof data.discount === 'string'
? JSON.parse(data.discount)
: data.discount;
store.setDiscount(d.value, d.type);
```
This is the v2 React equivalent of the pattern documented in `pocketbase-json-field-guard.md`. Without it, `setCustomerInfo(ci)` receives a string instead of an object, and the spread in the setter produces no usable fields — customer info stays empty while services (iterated via `forEach` on a string) may still partially work.
## Pitfall: jspdf v4.x compatibility
jspdf v4.0.0 had no breaking API changes aside from node filesystem access restrictions. All public API (`doc.text()`, `doc.splitTextToSize()`, `doc.roundedRect()`, `doc.setFont()`) works identically to v2.x. Use `type ColorTuple = [number, number, number]` for spreadable color arrays that satisfy TS strict mode.
## PDF → PNG Image Export
A new feature converts every page of a finished PDF into separate PNG downloads.
Requires `pdfjs-dist` (Mozilla's PDF.js) for server-side-like page rendering in the browser.
### Files
| File | Role |
|------|------|
| `src/lib/pdf-to-images.ts` | `downloadPdfAsImages()` — accepts `jsPDF` instance or `Blob`, renders each page to off-screen canvas, triggers PNG download |
| `src/main.tsx` | Configures `GlobalWorkerOptions.workerSrc` using Vite static-asset URL pattern |
| `package.json` | Added `pdfjs-dist` dependency |
### Dual-input design
`downloadPdfAsImages(pdfInput, customerName)` accepts either:
- **jsPDF instance** — calls `doc.output('arraybuffer')` internally
- **Blob** — reads via `blob.arrayBuffer()` (used by the Export as Images button, which calls `generateQuotePDF()` → gets a Blob → passes it directly)
This avoids re-parsing or rebuilding the doc when the caller only has the Blob.
### Rendering pipeline
```tsx
1. doc.output('arraybuffer') or blob.arrayBuffer()
2. pdfjsLib.getDocument({ data: arrayBuffer }).promise
3. For each page: page.getViewport({ scale: 2 }) → page.render() → canvas.toBlob('image/png')
4. Create , click it, revoke blob URL
```
Scale = 2 gives Retina-quality (144 DPI). Works entirely off-screen — no DOM flash.
### Worker setup (main.tsx)
```tsx
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
```
The `new URL(..., import.meta.url)` pattern tells Vite to treat the worker as a static asset for production builds.
### UI button
An **Export as Images** button sits between Print and Save Quote in the QuoteSummary panel. It calls `generateQuotePDF()` to get the Blob, then passes it to `downloadPdfAsImages()`. Uses the same `generating` loading state as the PDF download button.
## Pitfall: `applyShopCharge` default
When adding a new service to the quote (`handleAdd` in `ServiceSearch`), `applyShopCharge` must be explicitly set to `true`. The store's default (`undefined`) is falsy, so the shop charge calculation produces $0 and hides the row in the summary.
```tsx
// RIGHT:
addService({ ...service, selected: true, approved: true, customerDecision: 'approved', applyShopCharge: true });
// WRONG (produces $0 shop charge):
addService({ ...service, selected: true, approved: true, customerDecision: 'approved' });
```
The per-service "Shop charge" checkbox toggle in the expanded ServiceRow remains available for exceptions.
## Build & Deploy
```bash
cd /mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2
npx vite build
```
Build outputs to `dist/`, live immediately via nginx. The QuoteGenerator page lazy-loads as an async chunk (`QuoteGenerator-.js`). jspdf is bundled inline (~300KB).
## Debugging PDF Button "Nothing Happens"
If clicking Print/Download PDF does nothing:
1. Check browser console for errors
2. The `handlePdf()` catch block now shows a toast: "PDF generation failed: {message}"
3. Common causes: popup blocker (for Print), missing customer name or services (button should be disabled), jspdf import failure
4. Test blob URL creation: `new Blob(['test']); URL.createObjectURL(blob)` — if this works, jspdf is likely fine
5. If no error toast appears and no download/print happens, the handler may not be firing — check React fiber props on the button element