# 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