182 lines
No EOL
6.7 KiB
TypeScript
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;
|
|
}
|
|
} |