docs(superpowers): pdf stack overhaul design (react-pdf + unpdf)
Lays out the plan to replace pdfme with @react-pdf/renderer, add unpdf for berth-PDF tier-2 rasterization, and add port-level logo upload (sharp normalization + react-image-crop UI + svgo sanitization + rasterize-SVG-to-PNG-on-upload). Scope locked to internal-only PDFs (reports, expenses, record exports). Invoice + admin TipTap-to-PDF removed entirely; in-app EOI pathway (pdf-lib AcroForm fill) stays untouched. 14 commits planned. Single source of truth for tokens. Three orthogonal PDF paths post-migration with no overlap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
491
docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md
Normal file
491
docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
# PDF Stack Overhaul — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Branch:** `feat/documents-folders`
|
||||||
|
**Status:** Design approved; pending user review of spec; implementation planned via writing-plans skill.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace `pdfme` (3 deps, 8 hand-coded coordinate templates, 571-line TipTap-to-pdfme bridge) with `@react-pdf/renderer` (JSX components, real layout primitives). Add `unpdf` for berth-PDF tier-2 rasterization. Add port-level logo upload with quality safeguards. Migrate only the internal-only PDF surfaces; remove invoice and admin-TipTap PDF generation entirely (they violate the new "no client-facing CRM-generated PDFs" rule).
|
||||||
|
|
||||||
|
## Scope (locked)
|
||||||
|
|
||||||
|
### KEEP & migrate to `@react-pdf/renderer` (internal-only)
|
||||||
|
|
||||||
|
| Surface | Current location | Caller |
|
||||||
|
| ----------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||||
|
| Activity report | `src/lib/pdf/templates/reports/activity-report.ts` | `src/lib/services/reports.service.ts` |
|
||||||
|
| Revenue report | `src/lib/pdf/templates/reports/revenue-report.ts` | same |
|
||||||
|
| Pipeline report | `src/lib/pdf/templates/reports/pipeline-report.ts` | same |
|
||||||
|
| Occupancy report | `src/lib/pdf/templates/reports/occupancy-report.ts` | same |
|
||||||
|
| Client summary export | `src/lib/pdf/templates/client-summary-template.ts` | `src/lib/services/record-export.ts` |
|
||||||
|
| Berth spec export | `src/lib/pdf/templates/berth-spec-template.ts` | same |
|
||||||
|
| Interest summary export | `src/lib/pdf/templates/interest-summary-template.ts` | same |
|
||||||
|
| Expense sheet | `src/lib/services/expense-pdf.service.ts` (currently uses pdfme indirectly via `expense-export.ts`) | same |
|
||||||
|
|
||||||
|
### REMOVE entirely
|
||||||
|
|
||||||
|
| Removal | Reason |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `src/lib/pdf/templates/invoice-template.ts` + `generatePdf` call in `invoices.ts:604` + API route `/api/v1/invoices/[id]/generate-pdf` | Invoices are client-facing; no CRM-generated client-facing PDFs. Future invoice rendering will use the deferred AcroForm-fill admin-template feature. |
|
||||||
|
| `src/lib/pdf/tiptap-to-pdfme.ts` (571 lines) + API route `/api/v1/admin/templates/preview` + `generatePdf` block in `document-templates.ts:516` | TipTap document templates are Documenso seed bodies; CRM does not render them to PDF anymore. |
|
||||||
|
| `src/lib/pdf/templates/eoi-standard-inapp.ts` (337 lines, HTML seed) + seed-data references | Only used as the seed `bodyHtml` text on a `document_templates` row. The in-app EOI is rendered by `fill-eoi-form.ts` (pdf-lib), not from this HTML. Safe to drop. |
|
||||||
|
| `src/lib/pdf/generate.ts` (24 lines) | Pdfme wrapper; replaced by `src/lib/pdf/render.ts`. |
|
||||||
|
| Deps: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` | Replaced by `@react-pdf/renderer`. |
|
||||||
|
|
||||||
|
### STAYS UNTOUCHED
|
||||||
|
|
||||||
|
- `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm fill on `assets/eoi-template.pdf`) — the in-app EOI pathway.
|
||||||
|
- `src/lib/services/berth-pdf-parser.ts` tier-1 (pdf-lib AcroForm read) and tier-3 (AI fallback). Tier-2 (Tesseract OCR) gets `unpdf` for PDF→image rasterization.
|
||||||
|
- `pdf-lib` dep (still needed by `fill-eoi-form.ts` and `berth-pdf-parser.ts`).
|
||||||
|
- All Documenso integration code.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three orthogonal PDF paths post-migration, each with a single owner:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐
|
||||||
|
│ react-pdf (this phase) │ │ pdf-lib AcroForm fill │ │ Documenso (external) │
|
||||||
|
│ Internal only │ │ Standardized + signing │ │ Client-facing signed │
|
||||||
|
│ │ │ │ │ docs │
|
||||||
|
│ • Reports (×4) │ │ • In-app EOI │ │ │
|
||||||
|
│ • Expenses │ │ • Future admin-upload │ │ (handled outside our │
|
||||||
|
│ • Record exports (×3) │ │ invoice templates │ │ system) │
|
||||||
|
│ • Future internal lists │ │ (deferred) │ │ │
|
||||||
|
└────────────┬─────────────┘ └────────────┬─────────────┘ └────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
src/lib/pdf/render.ts src/lib/pdf/fill-eoi-form.ts
|
||||||
|
(renderToBuffer + (unchanged this phase)
|
||||||
|
renderToStream)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
src/lib/pdf/brand-kit/
|
||||||
|
├─ DocumentShell.tsx
|
||||||
|
├─ Header.tsx
|
||||||
|
├─ Footer.tsx
|
||||||
|
├─ DataTable.tsx
|
||||||
|
├─ KeyValueGrid.tsx
|
||||||
|
├─ Section.tsx
|
||||||
|
├─ Badge.tsx
|
||||||
|
├─ charts/{Bar,Line,Pie,Funnel}Chart.tsx
|
||||||
|
├─ tokens.ts
|
||||||
|
└─ logo.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module boundaries
|
||||||
|
|
||||||
|
- **`brand-kit/`** — pure presentation primitives. No DB access, no CRM domain knowledge. Each component has typed props and renders react-pdf elements.
|
||||||
|
- **`templates/`** — one `.tsx` per document type. Imports brand-kit primitives + receives typed data props. No DB access; data fetching stays in the calling service.
|
||||||
|
- **`render.ts`** — the only module that touches `@react-pdf/renderer`'s `renderToBuffer` / `renderToStream`. Services call `renderPdf(<MyTemplate {...data} />)` or `renderPdfStream(<MyTemplate {...data} />)`.
|
||||||
|
- **`logo.ts`** — `resolvePortLogo(portId)` reads `system_settings.port_logo_file_id` and returns `{ source, buffer, mimeType }`. Cached per request via React `cache()`.
|
||||||
|
- **Chart rendering** — pure SVG components emitting react-pdf's native `<Svg>` primitive. No JSDOM, no headless Chrome, no canvas. Server-rendered like any other PDF component.
|
||||||
|
- **Photo embedding** (expense PDFs) — `sharp` (existing dep) compresses each receipt to ~150KB JPEG before embed. Stream-renders pages so memory stays bounded with hundreds of entries.
|
||||||
|
|
||||||
|
### Header layout constraint
|
||||||
|
|
||||||
|
The brand-kit `<Header>` reserves a fixed logo slot:
|
||||||
|
|
||||||
|
```
|
||||||
|
maxWidth: 200 (≈ 56mm)
|
||||||
|
maxHeight: 60 (≈ 17mm)
|
||||||
|
objectFit: contain // letterbox, never stretch
|
||||||
|
align: left, vertically centered within the dark header band
|
||||||
|
fallback: when resolvePortLogo returns 'fallback', render <Text style={bold}>{port.name}</Text>
|
||||||
|
at the same slot. The port-name + doc-title combination keeps the header visually balanced.
|
||||||
|
```
|
||||||
|
|
||||||
|
This is enforced inside `<Header>`, not at upload time, so the upload pipeline can accept any 200-1200px logo and trust the layout to letterbox correctly.
|
||||||
|
|
||||||
|
### Brand kit tokens
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/pdf/brand-kit/tokens.ts
|
||||||
|
export const PDF_TOKENS = {
|
||||||
|
colors: {
|
||||||
|
text: '#111111',
|
||||||
|
textMuted: '#666666',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
headerBand: '#0f172a', // dark slate — matches CRM sidebar
|
||||||
|
headerText: '#ffffff',
|
||||||
|
accentBlue: '#1d4ed8',
|
||||||
|
zebra: '#f9fafb',
|
||||||
|
success: '#16a34a',
|
||||||
|
warning: '#d97706',
|
||||||
|
danger: '#dc2626',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
sans: 'Helvetica',
|
||||||
|
sansBold: 'Helvetica-Bold',
|
||||||
|
mono: 'Courier',
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
docTitle: 18,
|
||||||
|
sectionH: 13,
|
||||||
|
body: 10,
|
||||||
|
small: 8,
|
||||||
|
caption: 7,
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
pagePadding: 36,
|
||||||
|
sectionGap: 18,
|
||||||
|
rowGap: 6,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
Single source of truth. Future design pass = edit this file, every PDF updates.
|
||||||
|
|
||||||
|
## Logo handling
|
||||||
|
|
||||||
|
### Layer 1 — Server-side sharp normalization (required)
|
||||||
|
|
||||||
|
```
|
||||||
|
upload → magic-byte check via sharp metadata (PNG | JPEG | WEBP | SVG | HEIC | HEIF | AVIF)
|
||||||
|
→ reject animated GIF / multi-frame PNG / multi-page TIFF
|
||||||
|
→ size cap 5MB raw
|
||||||
|
→ if SVG:
|
||||||
|
sanitize first via svgo (strip <script>, on*=, <foreignObject>, external href)
|
||||||
|
reject if sanitization removed dangerous nodes
|
||||||
|
rasterize to PNG via sharp(buf, { density: 300 }) // 300 DPI from vector
|
||||||
|
→ standard pipeline:
|
||||||
|
sharp(buf)
|
||||||
|
.extract({ left: cropX, top: cropY, width: cropW, height: cropH }) ← from client crop
|
||||||
|
.trim({ threshold: 10 })
|
||||||
|
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.toColorspace('srgb')
|
||||||
|
.removeAlpha()-if-jpeg-source-and-near-white
|
||||||
|
.png({ compressionLevel: 9, palette: true }) ← palette where possible for smaller files
|
||||||
|
.toBuffer()
|
||||||
|
→ reject if final > 1MB
|
||||||
|
→ reject if min dimension after trim < 200px
|
||||||
|
→ store via getStorageBackend().put()
|
||||||
|
→ set system_settings.port_logo_file_id = files.id (atomic upsert)
|
||||||
|
→ soft-archive previous logo's files row (archivedAt = now)
|
||||||
|
→ write audit_logs entry: action=branding.logo.uploaded, by=user.id
|
||||||
|
→ collect warnings: [trimmed, resized, noAlpha, jpegSource, svgRasterized, heicConverted]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why rasterize SVGs to PNG at upload time:** react-pdf's `<Svg>` primitive supports a subset of SVG (Path, Rect, Circle, Line, Text, gradients, clip-paths) but not filters, animations, embedded fonts, or all the quirks of a designer-exported SVG. Sharp rasterizes via librsvg at 300 DPI on upload, eliminating runtime surprises. Single PNG to embed at render time. The vector source is captured-in-time; if the admin later needs higher resolution, they re-upload.
|
||||||
|
|
||||||
|
**Why HEIC/AVIF support:** iPhone photo exports default to HEIC; common admin pain point. Sharp handles both natively via libheif; converts to PNG in the pipeline. Less common but worth supporting.
|
||||||
|
|
||||||
|
### Layer 2 — Live upload UI
|
||||||
|
|
||||||
|
Admin opens **Port Settings → Branding → Logo**. The dialog shows:
|
||||||
|
|
||||||
|
1. **Rules above the dropzone:**
|
||||||
|
- Use PNG or SVG with a transparent background
|
||||||
|
- Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square)
|
||||||
|
- Max 5MB; we'll auto-trim and optimize
|
||||||
|
- Avoid JPEGs unless the background is solid white
|
||||||
|
|
||||||
|
2. **`react-image-crop` cropper** with aspect-ratio toggle (Wide 3:1 / Square 1:1 / Freeform).
|
||||||
|
|
||||||
|
3. **Live HTML preview** rendering the actual brand-kit `<Header>` React component beside the cropper, with the user's logo. Two preview swatches: dark header band (where the logo actually appears) and a colored background (to spot the "white box" problem with non-transparent JPEGs).
|
||||||
|
|
||||||
|
4. **Post-upload warnings** displayed in the preview:
|
||||||
|
- "JPEG with no alpha channel — white background will show on dark headers"
|
||||||
|
- "Logo trimmed to remove whitespace borders"
|
||||||
|
- "Resized from 4000×4000 to 1200×1200"
|
||||||
|
|
||||||
|
5. **"Test with sample PDF" button** — hits a sample-PDF endpoint that renders a minimal report header and streams it back. Browser opens in a new tab.
|
||||||
|
|
||||||
|
### Layer 3 — `react-image-crop` integration
|
||||||
|
|
||||||
|
Client renders the original image inside `react-image-crop` with a constrained aspect ratio. On save:
|
||||||
|
|
||||||
|
1. Client sends `multipart/form-data` with `file` + `{ cropX, cropY, cropW, cropH }` JSON sidecar.
|
||||||
|
2. Server runs the sharp pipeline above with the crop applied as the first step.
|
||||||
|
|
||||||
|
This keeps sharp as the single source of truth (no canvas-tainted-CORS issues client-side; the actual crop happens server-side using the user-provided coordinates).
|
||||||
|
|
||||||
|
### Storage path
|
||||||
|
|
||||||
|
Logos use the existing pluggable storage backend (`src/lib/storage/`). Object key shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
ports/{portId}/branding/logo-{uuid}.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The same backend currently serves brochures, berth PDFs, gdpr exports, etc. — `s3` for prod, `filesystem` for single-node dev. Logos inherit whatever's configured; no special routing. Trivial-image-inline-in-DB would save one S3 round-trip per PDF render but break consistency with every other file artifact; not worth it.
|
||||||
|
|
||||||
|
### Permission gating
|
||||||
|
|
||||||
|
The upload endpoint is wrapped with `withAuth(withPermission('port_settings', 'manage', …))` (same gate currently used for brochures admin, send-from accounts, etc.). Audit trail goes to `audit_logs` (`action: branding.logo.uploaded`, `entityType: port`, `entityId: portId`). Soft-archive of the prior logo file row is logged as `branding.logo.archived`.
|
||||||
|
|
||||||
|
### Resolution at render time
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/pdf/brand-kit/logo.ts
|
||||||
|
export const resolvePortLogo = cache(
|
||||||
|
async (
|
||||||
|
portId: string,
|
||||||
|
): Promise<{
|
||||||
|
source: 'logo' | 'fallback';
|
||||||
|
buffer: Buffer | null;
|
||||||
|
mimeType: 'image/png' | 'image/svg+xml' | null;
|
||||||
|
}> => {
|
||||||
|
const setting = await getSystemSetting(portId, 'port_logo_file_id');
|
||||||
|
if (!setting) return { source: 'fallback', buffer: null, mimeType: null };
|
||||||
|
const file = await db.query.files.findFirst({ where: eq(files.id, setting) });
|
||||||
|
if (!file || file.archivedAt) return { source: 'fallback', buffer: null, mimeType: null };
|
||||||
|
const backend = await getStorageBackend();
|
||||||
|
const buffer = await backend.get(file.storageKey);
|
||||||
|
return { source: 'logo', buffer, mimeType: file.mimeType as 'image/png' | 'image/svg+xml' };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Brand-kit `<DocumentShell>` internally calls this and passes the buffer down through context. Every template that wraps in `<DocumentShell port={port}>...</DocumentShell>` gets the logo automatically. No per-template wiring. When no logo is set, the header renders the port name as bold text instead.
|
||||||
|
|
||||||
|
## Per-template designs
|
||||||
|
|
||||||
|
### Reports — shared shell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [LOGO] PORT NAME REPORT TITLE │
|
||||||
|
│ generated 2026-05-12 18:44 Date-range badge │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Summary cards (3-4 KPI stat boxes) │
|
||||||
|
│ ┌──────┬──────┬──────┐ │
|
||||||
|
│ │
|
||||||
|
│ ◌ CHART (full-width SVG) │
|
||||||
|
│ │
|
||||||
|
│ Detail Table (zebra rows, columns vary per report) │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Port Name · Confidential · Page 1 of 3 · Generated … │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Report | Summary stat cards | Chart | Detail table columns |
|
||||||
|
| --------- | ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
|
| Activity | total events, top action, top user, busiest day | Stacked bar — events per day by action | date · action · entity type · entity · user |
|
||||||
|
| Revenue | total revenue, paid, outstanding, avg invoice | Line — revenue per month + small pie paid/outstanding | invoice # · client · issued · due · amount · status |
|
||||||
|
| Pipeline | total interests, win rate, avg cycle days, top stage | Funnel — count per stage | interest · client · stage · lead category · days in stage |
|
||||||
|
| Occupancy | total berths, occupied %, available %, under-offer % | Time-series — occupancy % over period + small pie current status | berth # · status · current interest · last change |
|
||||||
|
|
||||||
|
### Expense PDF
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [LOGO] PORT NAME — Expense Sheet │
|
||||||
|
│ Period: 2026-04-01 → 2026-04-30 · 247 entries │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Summary cards: total · by category · by status │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Expense entries (one row per entry, multi-page) │
|
||||||
|
│ ┌──┬──────────┬──────────┬────────┬─────────┬─────────┐ │
|
||||||
|
│ │# │ Date │ Category │ Vendor │ Amount │ Receipt │ │
|
||||||
|
│ │ │ Notes: <inline notes line, optional> │ │
|
||||||
|
│ │ │ [receipt photo, max 200×200, ~150KB JPEG] │ │
|
||||||
|
│ └──┴──────────┴──────────┴────────┴─────────┴─────────┘ │
|
||||||
|
│ Page break inserted between entries when remaining vertical │
|
||||||
|
│ space < 200px (no orphan partial rows) │
|
||||||
|
├──────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Page 1 of 47 · Total: $48,232 · 247 entries │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Critical: **stream-render via `renderToStream`** because 247 entries × ~150KB photos = 37MB peak memory if all loaded at once. Stream renders one page at a time, freeing buffers as it goes. Each photo passes through `sharp.resize(800, 800, { fit: 'inside' }).jpeg({ quality: 70 })` once and is cached for the lifetime of the request.
|
||||||
|
|
||||||
|
### Record exports
|
||||||
|
|
||||||
|
- **Client Summary** — brand shell + key/value grid for client info + table for yachts + table for interests + activity timeline at bottom.
|
||||||
|
- **Berth Spec** — brand shell + two-column key/value grid (info / dimensions / pricing / tenure) + infrastructure table + waiting-list table + maintenance-log table.
|
||||||
|
- **Interest Summary** — brand shell + stage badge in header + key/value grids for client/yacht/berth + notes block + activity timeline.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Caller migration pattern
|
||||||
|
|
||||||
|
Before:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { generatePdf } from '@/lib/pdf/generate';
|
||||||
|
import {
|
||||||
|
activityReportTemplate,
|
||||||
|
buildActivityInputs,
|
||||||
|
} from '@/lib/pdf/templates/reports/activity-report';
|
||||||
|
const inputs = buildActivityInputs(data, port.name);
|
||||||
|
const pdfBytes = await generatePdf(activityReportTemplate, inputs);
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderPdf } from '@/lib/pdf/render';
|
||||||
|
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
|
||||||
|
const pdfBytes = await renderPdf(<ActivityReportPdf port={port} data={data} />);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Render module
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/pdf/render.ts
|
||||||
|
import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export async function renderPdf(element: ReactElement): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
return await renderToBuffer(element);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'PDF render failed');
|
||||||
|
throw new Error('Failed to render PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdfStream(element: ReactElement): Promise<NodeJS.ReadableStream> {
|
||||||
|
return renderToStream(element);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chart rendering (sketch)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/lib/pdf/brand-kit/charts/BarChart.tsx
|
||||||
|
import { Svg, Line, Rect, Text as SvgText } from '@react-pdf/renderer';
|
||||||
|
import { PDF_TOKENS } from '../tokens';
|
||||||
|
|
||||||
|
export function BarChart({
|
||||||
|
data,
|
||||||
|
width = 480,
|
||||||
|
height = 200,
|
||||||
|
color = PDF_TOKENS.colors.accentBlue,
|
||||||
|
}) {
|
||||||
|
const max = Math.max(...data.map((d) => d.value));
|
||||||
|
const barW = (width - 60) / data.length;
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<Line
|
||||||
|
x1={40}
|
||||||
|
y1={20}
|
||||||
|
x2={40}
|
||||||
|
y2={height - 30}
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={PDF_TOKENS.colors.border}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
x1={40}
|
||||||
|
y1={height - 30}
|
||||||
|
x2={width - 10}
|
||||||
|
y2={height - 30}
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={PDF_TOKENS.colors.border}
|
||||||
|
/>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const h = (d.value / max) * (height - 60);
|
||||||
|
return (
|
||||||
|
<Rect
|
||||||
|
key={i}
|
||||||
|
x={50 + i * barW}
|
||||||
|
y={height - 30 - h}
|
||||||
|
width={barW - 4}
|
||||||
|
height={h}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<SvgText
|
||||||
|
key={i}
|
||||||
|
x={50 + i * barW + (barW - 4) / 2}
|
||||||
|
y={height - 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={7}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</SvgText>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for LineChart / PieChart / FunnelChart. ~60-100 lines each.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Failure mode | Detection | Surface |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Logo file missing at render time | `resolvePortLogo` returns `source: 'fallback'` | Header renders port-name text only; structured log warning. |
|
||||||
|
| Logo file corrupt | `sharp` throws on load | 500 via `errorResponse(InternalError)`; structured log; admin sees "Logo file is unreadable, please re-upload." |
|
||||||
|
| Chart data empty | Component prop validation in template | Render "No data for selected period" placeholder; no crash. |
|
||||||
|
| Receipt photo missing (expense PDF) | Storage backend `get` throws | Skip photo for that entry; render "Receipt unavailable" placeholder text; continue; collect into `warnings[]` and log. |
|
||||||
|
| Receipt photo unprocessable by sharp | `sharp` throws on resize | Same as above. |
|
||||||
|
| Stream-render aborted mid-page | `renderToStream` rejects | Caller drains stream into try/catch; surface `errorResponse(error)`; partial bytes not stored. |
|
||||||
|
| OOM on huge expense PDF | Heap monitor | Stream-render keeps peak bounded; cap entries at 1000 per PDF; prompt admin to split into multiple periods. |
|
||||||
|
| Sharp pipeline rejects upload | Specific error code | 422 `ValidationError` with the rejection reason ("file > 5MB", "dimension < 200px", "unsupported format: GIF animated"). |
|
||||||
|
| SVG with embedded JS or external href | `svgo` strips scripts; post-sanitize node-count check | Reject with `ValidationError('SVG contained disallowed nodes')`. |
|
||||||
|
| Concurrent logo uploads (admin clicks save twice / two browser tabs) | Last-writer-wins via atomic `system_settings` upsert | Both `files` rows persist; only newer is pointed at. Soft-archive doesn't race because it operates on the OLD setting's file_id captured before the upsert. |
|
||||||
|
| Mid-render logo upload | `resolvePortLogo` reads at render-start | In-flight PDF uses whichever logo was current when the request entered. Next request gets the new one. No mid-PDF logo swap. |
|
||||||
|
| Logo dimensions wildly off the header aspect ratio | Brand-kit `<Header>` constrains logo to `maxWidth: 200, maxHeight: 60` with `objectFit: contain` | Logo letterboxes inside its slot; never distorts. |
|
||||||
|
| Cropper coords out of bounds | Server-side validation against image metadata before sharp extract | 422 `ValidationError('Crop coordinates out of image bounds')`. |
|
||||||
|
| File mime header lies (claims PNG, bytes are HTML) | Sharp's `metadata()` reads actual magic bytes, ignores declared mime | Sharp throws → 422 `ValidationError('File contents do not match a supported image format')`. |
|
||||||
|
| Storage backend `put` fails (network glitch) | Catch around `backend.put` | Roll back: do not insert files row, do not change system_settings; return 503 with retry hint. |
|
||||||
|
| `port_logo_file_id` setting points at archived/deleted file | `resolvePortLogo` checks `archivedAt` | Treat as missing; fall back to text header; structured log warning so ops notices. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit (vitest)
|
||||||
|
|
||||||
|
- `brand-kit/charts/*.test.tsx` — snapshot SVG output for known inputs.
|
||||||
|
- `brand-kit/logo.test.ts` — `resolvePortLogo` with fixtures for: configured / missing / archived / corrupt.
|
||||||
|
- `pdf/render.test.ts` — round-trip a tiny `<Page>` and verify the output starts with `%PDF-`.
|
||||||
|
- `services/logo-upload.test.ts` — sharp pipeline for: PNG-with-alpha (passes) / JPEG (warning) / undersized (rejects) / oversized (resizes) / SVG (passthrough) / animated GIF (rejects) / SVG with script tag (rejects).
|
||||||
|
|
||||||
|
### Integration (vitest)
|
||||||
|
|
||||||
|
- Each template renders to bytes without throwing, given representative fixtures from seed data.
|
||||||
|
- `reports.service.test.ts` — generate each of the 4 reports for a seeded port; assert PDF magic byte + non-zero length.
|
||||||
|
- `record-export.test.ts` — generate client / berth / interest summaries for seeded entities.
|
||||||
|
- `expense-export.test.ts` — generate expense PDF for 250 seeded entries; assert pages > 5; assert peak heap delta < 200MB (proxy for stream-render working).
|
||||||
|
|
||||||
|
### Playwright (smoke)
|
||||||
|
|
||||||
|
- New spec: `branding-logo-upload.spec.ts` — upload PNG, see preview, save, generate sample PDF, assert PDF downloads.
|
||||||
|
- New spec: `reports-pdf-export.spec.ts` — for each of the 4 reports, click export, assert PDF downloads.
|
||||||
|
- Existing specs: anywhere clicking "export PDF" was tied to pdfme, update assertion.
|
||||||
|
|
||||||
|
### Visual regression (existing visual project)
|
||||||
|
|
||||||
|
- 4 new baselines (one per report) using seed port's logo.
|
||||||
|
- 3 new baselines (client / berth / interest summary).
|
||||||
|
- 1 new baseline (expense PDF, first 2 pages).
|
||||||
|
- Snapshots stored as PNG (rendered from PDF via first-page extraction).
|
||||||
|
|
||||||
|
## Migration sequence
|
||||||
|
|
||||||
|
| # | Commit | Files touched | Verifies |
|
||||||
|
| --- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
|
| 1 | Foundation: install deps + brand kit | +`@react-pdf/renderer`, +`unpdf`, +`react-image-crop`, +`svgo`; new `src/lib/pdf/brand-kit/*`, `src/lib/pdf/render.ts` | brand kit unit tests pass; nothing wired yet |
|
||||||
|
| 2 | Logo upload feature | new `src/lib/services/logo.service.ts`, `src/app/api/v1/admin/branding/logo/*`, admin UI in port settings, `system_settings.port_logo_file_id` key | upload + preview + sample-PDF test work in dev |
|
||||||
|
| 3 | Migrate activity report | port `activity-report.ts` → `activity-report.tsx`; rewire `reports.service.ts` caller; visual baseline | report exports work; visual diff approved |
|
||||||
|
| 4 | Migrate revenue report | same shape | same |
|
||||||
|
| 5 | Migrate pipeline report | same shape | same |
|
||||||
|
| 6 | Migrate occupancy report | same shape | same |
|
||||||
|
| 7 | Migrate client summary | port `client-summary-template.ts` → `.tsx`; rewire `record-export.ts` | same |
|
||||||
|
| 8 | Migrate berth spec | same | same |
|
||||||
|
| 9 | Migrate interest summary | same | same |
|
||||||
|
| 10 | Migrate expense PDF | port `expense-pdf.service.ts` to react-pdf streaming; sharp photo compression | 250-entry seed test passes |
|
||||||
|
| 11 | Remove invoice PDF generation | delete `invoice-template.ts`, the `generatePdf` call in `invoices.ts`, the API route `/api/v1/invoices/[id]/generate-pdf`; remove UI link | invoice list still works minus PDF button |
|
||||||
|
| 12 | Remove TipTap-→-pdfme bridge | delete `tiptap-to-pdfme.ts`, the preview route, the `generatePdf` block in `document-templates.ts:516`, the `getStandardEoiTemplateHtml` seed reference | admin template editor still saves; preview removed |
|
||||||
|
| 13 | Add unpdf to berth parser tier-2 | wire `unpdf` into `berth-pdf-parser.ts` for PDF→image rasterization; keep tesseract.js | berth PDF upload still parses |
|
||||||
|
| 14 | Cleanup: drop pdfme deps | remove `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` from package.json; delete `generate.ts`, `eoi-standard-inapp.ts`; clean up unused validators | `pnpm install` clean; no remaining imports |
|
||||||
|
|
||||||
|
Total: 14 commits. Most are small (5-15 file diffs). Commits 2, 10, and 12 are the heaviest. Vitest + tsc stay green throughout; each commit only flips behavior after its tests pass.
|
||||||
|
|
||||||
|
## Deferred (added to BACKLOG)
|
||||||
|
|
||||||
|
- Admin-uploaded PDF templates with AcroForm-fill (the invoice template-fill pattern). Needs: new `pdf_templates` table + field-mapping editor + admin upload UI + generalized `fillAcroForm()` utility. Likely ~1 week solo.
|
||||||
|
- Port brand color tokens (admin sets brand color → flows into PDF accent color). ~2h.
|
||||||
|
- Per-template logo override (different logo for invoices vs reports). YAGNI unless asked.
|
||||||
|
- Optical receipt-photo rotation/deskew (auto-rotate phone-upload receipts to readable orientation). ~half day.
|
||||||
|
- Replace tesseract.js with cloud OCR (AWS Textract / Google Vision) for berth parsing tier-2. Out of scope.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None blocking. Implementation can begin after user spec review.
|
||||||
Reference in New Issue
Block a user