Files
2026-07-12 10:17:17 -04:00

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:

  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 <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 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

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:

  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