/** * 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 { 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 { 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 { 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"; }