202 lines
6.9 KiB
TypeScript
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";
|
|
}
|