172 lines
7.9 KiB
Markdown
172 lines
7.9 KiB
Markdown
# 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.
|
|
|
|
```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 <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)
|
|
|
|
```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-<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
|