7.9 KiB
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:
- Header box (light gray bg, two-column: SERVICE PROVIDER left, CUSTOMER INFORMATION right)
- Personalized message (template vars
{customerName},{vehicleInfo}) - Services section — split into APPROVED / POSTPONED if mixed decisions
- Priority headers with icons ([CRITICAL], [SAFETY], etc.) and reasons
- Customer decision badges (✓ CUSTOMER APPROVED green, ✗ CUSTOMER DECLINED red with strikethrough)
- Parts status flags rendered inline per service (see table above)
- Pricing summary box (subtotal, approved, discount, shop charge, tax, TOTAL)
- Shop charge explanation footnote
- 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 <input> in ServiceRow expanded section |
editPartsDeliveryTime() |
inline <input> 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=<id>, call reset() FIRST, then setCustomerInfo(). The old code called setCustomerInfo() before reset(), which immediately wiped customer data back to empty.
// 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:
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 callsgenerateQuotePDF()→ gets a Blob → passes it directly)
This avoids re-parsing or rebuilding the doc when the caller only has the Blob.
Rendering pipeline
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 <a download="...">, click it, revoke blob URL
Scale = 2 gives Retina-quality (144 DPI). Works entirely off-screen — no DOM flash.
Worker setup (main.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.
// 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
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-<hash>.js). jspdf is bundled inline (~300KB).
Debugging PDF Button "Nothing Happens"
If clicking Print/Download PDF does nothing:
- Check browser console for errors
- The
handlePdf()catch block now shows a toast: "PDF generation failed: {message}" - Common causes: popup blocker (for Print), missing customer name or services (button should be disabled), jspdf import failure
- Test blob URL creation:
new Blob(['test']); URL.createObjectURL(blob)— if this works, jspdf is likely fine - If no error toast appears and no download/print happens, the handler may not be firing — check React fiber props on the button element