This commit adds full quote PDF generation functionality with per-company letterhead branding: - Server-side PDF rendering using @react-pdf/renderer matching the sample quote layout - In-app modal PDF viewer with print, download, and new-tab controls - Letterhead service to fetch and apply company-specific branding (CNL/ISHERWOOD) - ZAR currency and date formatting utilities for South African quotes - Updated QuoteAttachment type to handle PocketBase's array-based file tokens - Fixed PocketBase attachment URL generation in quote email card - Removed deprecated `included_in_quote` field from QuoteItem and quotes service - Updated quote item creation test to validate draft item logic - Added comprehensive unit tests for PDF rendering and formatting helpers - Added dev scripts for debugging PDF and image rendering issues - Added sample generated PDF files for visual verification - Updated tsconfig to exclude development scripts directory - Added @react-pdf/renderer production dependency - Removed JEI as a valid letterhead option per requirements
8.2 KiB
Plan — Quote PDF Generation (Letterhead-driven)
Created: 2026-06-23 00:20 SAST Status: In Progress Skill:
@react-pdf/renderer(lightweight, no Chromium in Docker) Scope: Generate per-company-branded PDF quotes on the Process Quote page, auto-save toquote_attachments, flip quote status toprocessed.
Executive Summary
Replace the three "Generate Quote PDF" placeholder buttons (CNL / ISherwood / JEI) on src/app/(app)/quotes/[id]/process/page.tsx with a working flow: click → server-side render a per-letterhead PDF using @react-pdf/renderer → open an in-app viewer modal with Print / Download / Close → auto-attach the PDF to quote_attachments → flip quote status to processed. Drop JEI from quotes.company_letterhead select (never had a letterhead row, zero data risk). Add a per-row accent_color to letterhead_settings so each company can theme its PDF independently. Every PDF render reads fresh from PocketBase — changes propagate on the next quote.
Goals
- One click on the Process Quote page generates a properly branded PDF for CNL or ISherwood.
- Letterhead data (logo, address, banking, terms, accent color) is read fresh from PB on every render — no caching, no stale PDFs.
- PDF is auto-saved to
quote_attachmentscollection (kind ="pdf", linked to quote). - Quote
statusflips toprocessedafter successful generation (only from valid pre-states:new,claimed,problem). - In-app PDF viewer with Print / Download / Close — no auto-download.
- JEI removed from the dropdown, button removed from the UI.
Non-Goals
- Email sending (separate flow, owned by
quote-email-card). - PDF editing / annotations.
- Multi-page quote layouts (defer until needed — current quotes fit on 1 page).
- Currency localisation beyond ZAR.
Background
The Process Quote page already exposes three buttons (CNL / ISherwood / JEI) wired to toast.info("PDF generation coming soon"). The letterhead_settings PB collection has 2 rows (CNL + ISherwood) with logos uploaded and full banking / terms data. The quote_attachments collection already supports application/pdf MIME and a kind: "pdf" enum. quotes.pdf_generated_at field exists but is never written. quotes.status has "processed" in its enum but no flow sets it.
The user provided an existing PDF sample (/Volumes/KIOXIA/Downloads/Quote-CNL-Q009638-20260622134000.pdf) showing the target design: top-left company block, top-right logo, big QUOTATION heading, two-column customer/quote-meta, olive accent table header, totals right-aligned, terms + banking footer.
Detailed Design
Architecture
Process Quote page (button click)
│
▼
POST /api/v1/quotes/[id]/pdf
│
├── 1. Fetch quote + items + customer + letterhead row (PB admin)
├── 2. Fetch logo bytes from PB file URL (cached per request)
├── 3. Render <QuoteDocument> to PDF Buffer (@react-pdf Node API)
├── 4. Save PDF to quote_attachments (kind="pdf")
├── 5. PATCH quote: status=processed, pdf_generated_at=now
└── 6. Return { pdfId, attachmentId, status }
│
▼
Client receives JSON → opens QuotePdfViewer modal
│
└── Modal fetches PDF blob from /api/v1/quotes/[id]/pdf?attachmentId=xxx
Shows <iframe src={blobUrl}> + Print + Download + Close buttons
Data Model Changes (PocketBase)
letterhead_settings — add field:
{
"name": "accent_color",
"type": "text",
"required": false,
"max": 7,
"pattern": "^#[0-9A-Fa-f]{6}$"
}
Seed values:
- CNL row:
accent_color = "#738224"(olive — matches logo swoosh in sample PDF) - Isherwood row:
accent_color = "#7ed957"(lime)
quotes.company_letterhead — drop "JEI" from select options:
["CNL", "ISHERWOOD"]
API Design
POST /api/v1/quotes/[id]/pdf
Request body:
{ "letterhead": "CNL" | "ISHERWOOD" }
Response:
{
"ok": true,
"attachmentId": "abc123",
"status": "processed",
"pdfGeneratedAt": "2026-06-23T00:30:00Z"
}
Errors:
404— quote or letterhead not found409— quote in terminal state (already approved/emailed/complete) and cannot regenerate422— no processed items to quote500— render failure
GET /api/v1/quotes/[id]/pdf?attachmentId=xxx
Returns the PDF binary (application/pdf) for the viewer iframe. Streams from PB file URL with admin auth.
User Flow
- User opens Process Quote page for a quote in
new/claimedstate. - User clicks
CNLbutton → spinner → modal opens showing the PDF. - Modal:
<iframe>displays PDF, header has[Print][Download][Close]buttons. - On close, modal disappears. Toast confirms "Quote PDF saved + status updated to processed".
- On reload, the page shows the new status badge.
Component Tree
<QuoteProcessPage>
<Button CNL onClick={generatePdf("CNL")} />
<Button ISherwood onClick={generatePdf("ISHERWOOD")} />
{pdfModal && (
<QuotePdfViewer
blobUrl={pdfModal.blobUrl}
filename={pdfModal.filename}
onClose={...}
/>
)}
</QuoteProcessPage>
Implementation Approach
Files to Create
| File | Purpose |
|---|---|
src/lib/letterhead.ts |
Letterhead type, fetchLetterhead(letterhead) helper |
src/lib/pdf/quote-document.tsx |
<QuoteDocument> React-PDF component matching sample layout |
src/lib/pdf/render.ts |
renderQuotePdf(props) → Buffer (uses pdf() from @react-pdf/renderer) |
src/lib/pdf/format.ts |
formatZAR, formatDate, addDays (valid-until = quote_date + 30) |
src/app/api/v1/quotes/[id]/pdf/route.ts |
POST (generate + save) + GET (stream PDF blob) |
src/components/quote-pdf-viewer.tsx |
Modal with iframe + Print/Download/Close |
Files to Modify
| File | Change |
|---|---|
src/app/(app)/quotes/[id]/process/page.tsx |
Remove JEI button, wire CNL/ISherwood to viewer, add modal state |
package.json |
Added @react-pdf/renderer (already done) |
PocketBase Changes
- PATCH
letterhead_settingscollection → addaccent_colorfield - PATCH
quotescollection → remove"JEI"fromcompany_letterheadvalues - PATCH CNL letterhead row → set
accent_color = "#738224" - PATCH Isherwood letterhead row → set
accent_color = "#7ed957"
Testing Strategy
- Unit (
src/lib/pdf/render.test.ts): render with mock data → assert Buffer is non-empty, starts with%PDF-, length > 1KB. - Unit (
src/lib/pdf/format.test.ts): ZAR formatting, date arithmetic. - Integration (smoke): with dev server running, curl POST the API route → assert 200 + JSON shape. Verify
quote_attachmentsgot a new row + quote status flipped. - Visual: open the Process page, click CNL, eyeball the modal PDF matches the sample layout.
Rollout Plan
- Phase 1 (now): Code complete, dev server smoke test passes, unit tests green.
- Phase 2: Deploy to Coolify, manual test on 1 live quote, eyeball the output, adjust spacing if needed.
- Phase 3 (deferred): Wire into the outbound email flow so the PDF auto-attaches.
Risks
- Server-side render in Next.js 16 route handler —
@react-pdf/renderer's Node API works fine in Route Handlers (no DOM needed), but we need to make sure the route isruntime = "nodejs"(not edge). Will set explicitly. - Logo fetch from PB — public URL works (no auth) for file thumbnails and downloads. We'll use the same URL pattern. If PB is behind auth, we'd need admin token — will verify.
- Puppeteer/Playwright dev conflicts —
@react-pdf/rendereris purely Node, no Chromium. Should not conflict with the@playwright/testalready in devDeps. pdf_generated_atfield is text in schema — checking, will store ISO string.- Existing quotes with
company_letterhead = "CNL"but nopdf_generated_at— those just stay as-is; new generations write the field.
Open Questions
None — user gave green light + colors + sample PDF.
Approval
- User green-lit
@react-pdf/rendererapproach - User provided accent colors: CNL
#738224, Isherwood#7ed957 - User provided sample PDF for layout matching
- User confirmed: auto-save to
quote_attachments, in-app viewer (not download), status →processed, JEI removed