WFDASH-WEBAPP/docs/superpowers/plans/2026-06-23-quote-pdf-generation.md
SJCoetz 714e910949 feat: add branded quote PDF generation and in-app viewer
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
2026-06-23 08:30:38 +02:00

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 to quote_attachments, flip quote status to processed.


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_attachments collection (kind = "pdf", linked to quote).
  • Quote status flips to processed after 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 found
  • 409 — quote in terminal state (already approved/emailed/complete) and cannot regenerate
  • 422 — no processed items to quote
  • 500 — 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

  1. User opens Process Quote page for a quote in new / claimed state.
  2. User clicks CNL button → spinner → modal opens showing the PDF.
  3. Modal: <iframe> displays PDF, header has [Print] [Download] [Close] buttons.
  4. On close, modal disappears. Toast confirms "Quote PDF saved + status updated to processed".
  5. 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

  1. PATCH letterhead_settings collection → add accent_color field
  2. PATCH quotes collection → remove "JEI" from company_letterhead values
  3. PATCH CNL letterhead row → set accent_color = "#738224"
  4. 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_attachments got 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

  1. 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 is runtime = "nodejs" (not edge). Will set explicitly.
  2. 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.
  3. Puppeteer/Playwright dev conflicts — @react-pdf/renderer is purely Node, no Chromium. Should not conflict with the @playwright/test already in devDeps.
  4. pdf_generated_at field is text in schema — checking, will store ISO string.
  5. Existing quotes with company_letterhead = "CNL" but no pdf_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/renderer approach
  • 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