192 lines
8.2 KiB
Markdown
192 lines
8.2 KiB
Markdown
|
|
# 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:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"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:
|
||
|
|
```json
|
||
|
|
{ "letterhead": "CNL" | "ISHERWOOD" }
|
||
|
|
```
|
||
|
|
|
||
|
|
Response:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"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
|
||
|
|
|
||
|
|
- [x] User green-lit `@react-pdf/renderer` approach
|
||
|
|
- [x] User provided accent colors: CNL `#738224`, Isherwood `#7ed957`
|
||
|
|
- [x] User provided sample PDF for layout matching
|
||
|
|
- [x] User confirmed: auto-save to `quote_attachments`, in-app viewer (not download), status → `processed`, JEI removed
|