WFDASH-WEBAPP/src/lib/letterhead.ts
2026-09-02 01:17:05 +02:00

182 lines
No EOL
6.7 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 / brand has exactly one row. 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 / DN / PO without a code deploy.
*/
export type LetterheadCode = "CNL" | "ISHERWOOD" | "WESTSCOTT GROUP OF COMPANIES";
export const LETTERHEAD_CODES: readonly LetterheadCode[] = [
"CNL",
"ISHERWOOD",
"WESTSCOTT GROUP OF COMPANIES",
] as const;
/**
* Canonical letterhead for ALL delivery-note PDFs, regardless of
* which subsidiary the customer belongs to. The subsidiary name
* still appears in the PDF meta block (so the printed document
* tells the driver which subsidiary the delivery is for) but the
* letterhead/header is always the Westcott Group one.
*
* Centralised here so the delivery-notes service and the invoicing
* API (which both render delivery-note PDFs) can't drift apart
* again. If the brand ever needs to change, this is the only
* constant to touch.
*/
export const DELIVERY_NOTE_LETTERHEAD_CODE: LetterheadCode =
"WESTSCOTT GROUP OF COMPANIES";
/**
* Helper that returns the canonical delivery-note letterhead code.
* Use this anywhere a delivery-note PDF is being rendered so the
* three-letter `?? "CNL"` fallbacks that used to live in the
* invoicing API routes stay gone.
*/
export function deliveryNoteLetterheadCode(): LetterheadCode {
return DELIVERY_NOTE_LETTERHEAD_CODE;
}
/**
* Letterhead for Repair Request PDFs (the "send to supplier for
* analysis" document). Always CNL — a repair request is a paper
* document the office hands to the supplier with the machine, and
* it identifies the *sender* (the office), not the recipient.
* Centralised so the service layer + the (future) any-other
* Repair Request renderer can't drift apart.
*/
export const REPAIR_REQUEST_LETTERHEAD_CODE: LetterheadCode = "CNL";
/**
* Helper that returns the canonical Repair Request letterhead code.
* Mirror of `deliveryNoteLetterheadCode()`.
*/
export function repairRequestLetterheadCode(): LetterheadCode {
return REPAIR_REQUEST_LETTERHEAD_CODE;
}
/**
* Pick the letterhead code that matches a given company name. The
* invoice / delivery-note flows don't always carry an explicit
* letterhead (the operator just hits "Send"), so we infer it from
* the order's billing company — "ISHERWOOD" or "CNL" in the name
* wins, otherwise we fall back to `null` and the caller decides
* (typically defaulting to CNL).
*/
export function letterheadForCompany(companyName: string | undefined | null): LetterheadCode | null {
const upper = (companyName ?? "").trim().toUpperCase();
if (!upper) return null;
if (upper.includes("ISHERWOOD")) return "ISHERWOOD";
if (upper.includes("CNL")) return "CNL";
return null;
}
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;
}
}