RJBULK-APP/lib/pdf/shared.ts
2026-07-21 13:15:42 +02:00

202 lines
6.9 KiB
TypeScript

/**
* Shared PDF helpers for statements and reports.
*
* Uses pdf-lib to compose A4 PDFs in Node (Edge-compatible).
*/
import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib";
export const COMPANY = {
name: "RJ Bulk Supplies",
tagline: "Fuel delivery specialists",
address: "Gauteng, South Africa",
email: "accounts@rjbulk.co.za",
vat: "4023456789",
};
export interface PDFContext {
doc: PDFDocument;
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
y: number;
}
export async function newPDF(): Promise<PDFContext> {
const doc = await PDFDocument.create();
doc.setTitle(COMPANY.name);
doc.setAuthor(COMPANY.name);
const font = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const page = doc.addPage([595, 842]); // A4
return { doc, page, font, fontBold, y: 800 };
}
export function addPage(ctx: PDFContext): void {
ctx.page = ctx.doc.addPage([595, 842]);
ctx.y = 800;
}
export const COLORS = {
primary: rgb(0.85, 0.45, 0.18), // matches orange theme
text: rgb(0.13, 0.16, 0.22),
muted: rgb(0.5, 0.55, 0.6),
border: rgb(0.85, 0.88, 0.9),
bgAlt: rgb(0.97, 0.98, 0.98),
};
export function drawHeader(ctx: PDFContext, title: string, subtitle?: string): void {
const { page, font, fontBold } = ctx;
// Brand block
page.drawRectangle({ x: 40, y: 780, width: 60, height: 30, color: COLORS.primary });
page.drawText("RJ", { x: 55, y: 790, size: 18, font: fontBold, color: rgb(1, 1, 1) });
page.drawText(COMPANY.name, { x: 110, y: 793, size: 14, font: fontBold, color: COLORS.text });
page.drawText(COMPANY.tagline, { x: 110, y: 781, size: 8, font, color: COLORS.muted });
// Title (right)
const titleSize = 16;
const titleW = fontBold.widthOfTextAtSize(title, titleSize);
page.drawText(title, { x: 555 - titleW, y: 790, size: titleSize, font: fontBold, color: COLORS.text });
if (subtitle) {
const subW = font.widthOfTextAtSize(subtitle, 9);
page.drawText(subtitle, { x: 555 - subW, y: 778, size: 9, font, color: COLORS.muted });
}
// Separator
page.drawLine({
start: { x: 40, y: 770 },
end: { x: 555, y: 770 },
thickness: 0.5,
color: COLORS.border,
});
ctx.y = 750;
}
export function drawText(ctx: PDFContext, text: string, opts: { x?: number; y?: number; size?: number; bold?: boolean; color?: any } = {}): void {
const { font, fontBold, page } = ctx;
const size = opts.size ?? 10;
page.drawText(text, {
x: opts.x ?? 40,
y: opts.y ?? ctx.y,
size,
font: opts.bold ? fontBold : font,
color: opts.color ?? COLORS.text,
});
}
export function moveDown(ctx: PDFContext, n = 14): void {
ctx.y -= n;
}
export function ensureSpace(ctx: PDFContext, needed = 30, headerTitle = ""): void {
if (ctx.y - needed < 60) {
addPage(ctx);
if (headerTitle) drawHeader(ctx, headerTitle);
}
}
export interface Column {
header: string;
width: number; // pt
align?: "left" | "right" | "center";
format?: (val: any) => string;
}
export function drawTable(
ctx: PDFContext,
columns: Column[],
rows: any[][],
options: { includeHeader?: boolean; rowHeight?: number } = {},
): void {
// NOTE: do NOT destructure `page` from ctx — addPage(ctx) reassigns ctx.page,
// and drawing on a frozen local would land on the wrong page (the bug that
// caused the whole table to overflow onto the summary card / customer info).
const { font, fontBold } = ctx;
const rowHeight = options.rowHeight ?? 16;
const startX = 40;
const totalWidth = columns.reduce((a, c) => a + c.width, 0);
if (options.includeHeader !== false) {
ctx.page.drawRectangle({ x: startX, y: ctx.y - rowHeight, width: totalWidth, height: rowHeight, color: COLORS.bgAlt });
let x = startX + 4;
for (const col of columns) {
ctx.page.drawText(col.header, { x, y: ctx.y - 12, size: 9, font: fontBold, color: COLORS.muted });
x += col.width;
}
ctx.y -= rowHeight;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const isLast = i === rows.length - 1;
if (ctx.y - rowHeight < 60) {
addPage(ctx);
// repeat header on new page — must draw on ctx.page, not a frozen local
ctx.page.drawRectangle({ x: startX, y: ctx.y - rowHeight, width: totalWidth, height: rowHeight, color: COLORS.bgAlt });
let hx = startX + 4;
for (const col of columns) {
ctx.page.drawText(col.header, { x: hx, y: ctx.y - 12, size: 9, font: fontBold, color: COLORS.muted });
hx += col.width;
}
ctx.y -= rowHeight;
}
let x = startX + 4;
for (let ci = 0; ci < columns.length; ci++) {
const col = columns[ci];
const val = col.format ? col.format(row[ci]) : String(row[ci] ?? "");
const textWidth = font.widthOfTextAtSize(val, 9);
let dx = x;
if (col.align === "right") dx = x + col.width - 8 - textWidth;
else if (col.align === "center") dx = x + (col.width - textWidth) / 2;
ctx.page.drawText(val, { x: dx, y: ctx.y - 12, size: 9, font, color: COLORS.text });
x += col.width;
}
// Row separator — skip the last row so callers can write directly below
if (!isLast) {
ctx.page.drawLine({
start: { x: startX, y: ctx.y - rowHeight },
end: { x: startX + totalWidth, y: ctx.y - rowHeight },
thickness: 0.3,
color: COLORS.border,
});
}
ctx.y -= rowHeight;
}
}
export function drawFooter(ctx: PDFContext, pageNum: number, total: number): void {
const { doc, page, font } = ctx;
const stamp = `Generated ${new Date().toLocaleString("en-ZA")} • Page ${pageNum} of ${total}`;
const w = font.widthOfTextAtSize(stamp, 8);
page.drawText(stamp, { x: 555 - w, y: 30, size: 8, font, color: COLORS.muted });
}
/** Add page numbers to all pages after the document is fully built. */
export async function finalizePDF(ctx: PDFContext): Promise<Uint8Array> {
const pages = ctx.doc.getPages();
pages.forEach((p, i) => {
drawFooter(ctx, i + 1, pages.length);
// Reuse the page object indirectly — pdf-lib doesn't allow re-drawing on a different page from context.
// Instead, draw directly on the page:
const stamp = `Page ${i + 1} of ${pages.length}`;
const w = ctx.font.widthOfTextAtSize(stamp, 8);
p.drawText(stamp, { x: 555 - w, y: 30, size: 8, font: ctx.font, color: COLORS.muted });
});
return ctx.doc.save();
}
/** Replace drawFooter above with this simpler implementation that doesn't use ctx.page. */
export async function finalizePDFSimple(ctx: PDFContext): Promise<Uint8Array> {
return ctx.doc.save();
}
export function periodLabel(range: string, start?: string, end?: string): string {
if (range === "this-month") return new Date().toLocaleString("en-ZA", { month: "long", year: "numeric" });
if (range === "last-month") {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toLocaleString("en-ZA", { month: "long", year: "numeric" });
}
if (start && end) return `${start} to ${end}`;
return "Custom";
}