WFDASH-WEBAPP/src/lib/letterhead.ts
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

120 lines
No EOL
4.4 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
import { getAdminPocketBase } from "./server/pocketbase-admin";
import type { BaseRecord } from "./types";
/**
* Letterhead — server-side read model for a row in `letterhead_settings`.
*
* Each company (CNL, ISHERWOOD) has exactly one row (enforced by the
* `idx_letterhead_settings_one_per_company` unique index). The PDF
* generator fetches this row fresh on every render so edits in the
* PB admin UI (logo swap, bank detail fix, terms tweak, accent color
* change) propagate to the next quote without a code deploy.
*/
export type LetterheadCode = "CNL" | "ISHERWOOD";
export const LETTERHEAD_CODES: readonly LetterheadCode[] = ["CNL", "ISHERWOOD"] as const;
export interface Letterhead extends BaseRecord {
display_name: string;
address: string;
phone: string;
email: string;
vat_number: string;
registration_number: string;
bank_name: string;
bank_account_number: string;
bank_branch_code: string;
terms: string;
default_markup_percent: number;
footer_text: string;
/** PB file token (filename) — combine with `letterheadLogoUrl` for a usable URL. */
logo: string;
is_active: boolean;
company: string;
/** Hex like "#738224". Set per-company so the PDF table header + accents differ. */
accent_color: string;
}
/**
* Look up the letterhead row for a given company code.
*
* Matches on the `display_name` field (e.g. "CNL Mining Supplies" matches
* the `CNL` code). Throws if no row matches — that's a config bug, not a
* user error, so it surfaces as a 500 in the API.
*/
export async function fetchLetterhead(code: LetterheadCode): Promise<Letterhead> {
if (!LETTERHEAD_CODES.includes(code)) {
throw new Error(`Unknown letterhead code: ${code}`);
}
const pb = await getAdminPocketBase();
const rows = await pb.collection("letterhead_settings").getFullList<Letterhead>({
filter: `display_name ~ "${code}"`,
});
if (rows.length === 0) {
throw new Error(
`No letterhead_settings row matching code ${code}. ` +
`Seed one in the PB admin UI (Settings → Collections → letterhead_settings).`
);
}
return rows[0];
}
/**
* Public file URL for the logo. PB's `/api/files/{collection}/{record}/{filename}`
* endpoint serves without auth, so the PDF renderer can fetch this directly
* — though we still inline as data URI in the API route to avoid extra
* round trips during render and to keep the document component pure.
*/
export function letterheadLogoUrl(row: Letterhead): string | null {
if (!row.logo) return null;
const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
return `${base}/api/files/${row.collectionId}/${row.id}/${row.logo}`;
}
/** Default accent for a code if the row has no `accent_color` set. */
export function defaultAccentFor(code: LetterheadCode): string {
return code === "CNL" ? "#738224" : "#7ed957";
}
/**
* Load the company logo SVG from `public/letterhead/<code>.svg` and
* return it as a `data:image/svg+xml;base64,...` URI suitable for
* `<Image src={...}>` in @react-pdf.
*
* We embed SVG instead of the original raster (PNG/JPEG) logos because
* @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug that
* replaces every byte ≥ 0x80 in raster image data with `0xFD`. SVG is
* text-based, so it sidesteps that code path — @react-pdf parses the
* SVG XML and embeds vector primitives directly into the PDF stream.
*
* **Base64, not URL-encoded.** The bundled PDFKit only matches
* `^data:.+?;base64,(.*)$` when decoding inline data URIs (see
* `PDFImage.open` in `@react-pdf/pdfkit/lib/pdfkit.js`). A
* `;charset=utf-8,<urlencoded>` URI is treated as a filesystem path
* and silently dropped. Base64 it is — yes, the data URI is ~33%
* bigger than URL-encoded, but for a 1x-per-quote render that's
* a non-issue.
*
* Returns `null` if the file is missing or unreadable — that's a
* non-fatal degradation (the PDF still renders, just without a logo),
* so callers should treat it as best-effort.
*/
export async function loadLetterheadLogoDataUri(
code: LetterheadCode
): Promise<string | null> {
try {
const filePath = path.join(
process.cwd(),
"public",
"letterhead",
`${code.toLowerCase()}.svg`
);
const raw = await readFile(filePath);
return `data:image/svg+xml;base64,${raw.toString("base64")}`;
} catch (err) {
console.warn(`[letterhead] could not load logo for ${code}:`, err);
return null;
}
}