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
120 lines
No EOL
4.4 KiB
TypeScript
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;
|
|
}
|
|
} |