RJBULK-APP/lib/pdf/customer-statement.ts

127 lines
5.3 KiB
TypeScript
Raw Normal View History

import {
newPDF,
drawHeader,
drawText,
drawTable,
moveDown,
COLORS,
type PDFContext,
} from "./shared";
import type { Customer, DeliveryNote, FuelSettings } from "../types";
import { formatCurrency, formatDate, formatNumber } from "../utils";
export interface StatementInput {
customer: Customer;
notes: DeliveryNote[];
startDate: string;
endDate: string;
includePricing: boolean;
companySettings: FuelSettings | null;
}
export async function generateCustomerStatement(input: StatementInput): Promise<Uint8Array> {
const ctx = await newPDF();
drawHeader(
ctx,
"Delivery Statement",
`${formatDate(input.startDate)} to ${formatDate(input.endDate)}`,
);
// Customer info block — two columns at the same y, then a few lines under the left column
const c = input.customer;
const infoTopY = ctx.y;
drawText(ctx, "Bill to", { x: 40, y: infoTopY, size: 8, color: COLORS.muted });
drawText(ctx, "Statement period", { x: 320, y: infoTopY, size: 8, color: COLORS.muted });
const nameY = infoTopY - 14;
drawText(ctx, c.name, { x: 40, y: nameY, size: 12, bold: true });
drawText(ctx, `${formatDate(input.startDate)} to ${formatDate(input.endDate)}`, { x: 320, y: nameY, size: 10, bold: true });
let y = nameY - 14;
if (c.email) { drawText(ctx, c.email, { x: 40, y, size: 9, color: COLORS.muted }); y -= 12; }
if (c.contact_number) { drawText(ctx, c.contact_number, { x: 40, y, size: 9, color: COLORS.muted }); y -= 12; }
if (c.address) { drawText(ctx, c.address, { x: 40, y, size: 9, color: COLORS.muted }); y -= 12; }
if (c.vat_number) { drawText(ctx, `VAT: ${c.vat_number}`, { x: 40, y, size: 9, color: COLORS.muted }); y -= 12; }
ctx.y = y - 8; // resume below the info block
// Summary card — three columns, label at top, value below
const totalQty = input.notes.reduce((a, n) => a + (n.qty_litres_supplied || 0), 0);
const totalValue = input.notes.reduce((a, n) => a + (n.total_value || 0), 0);
const cardTop = ctx.y - 4;
const cardHeight = 56;
const cardBottom = cardTop - cardHeight;
ctx.page.drawRectangle({
x: 40, y: cardBottom, width: 515, height: cardHeight,
color: COLORS.bgAlt, borderColor: COLORS.border, borderWidth: 0.5,
});
// Column 1: Records
drawText(ctx, "Records", { x: 56, y: cardTop - 14, size: 8, color: COLORS.muted });
drawText(ctx, String(input.notes.length), { x: 56, y: cardTop - 36, size: 16, bold: true });
// Column 2: Total quantity
drawText(ctx, "Total quantity", { x: 220, y: cardTop - 14, size: 8, color: COLORS.muted });
drawText(ctx, `${formatNumber(totalQty)} L`, { x: 220, y: cardTop - 36, size: 16, bold: true });
// Column 3: Total value
drawText(ctx, "Total value", { x: 400, y: cardTop - 14, size: 8, color: COLORS.muted });
drawText(ctx, formatCurrency(totalValue), { x: 400, y: cardTop - 36, size: 16, bold: true });
ctx.y = cardBottom - 12;
// Detailed table
const columns: { header: string; width: number; align?: "left" | "right" | "center"; format?: (v: any) => string }[] = [
{ header: "Date", width: 70, format: (v: string) => formatDate(v) },
{ header: "Note #", width: 80 },
{ header: "Vehicle", width: 90 },
{ header: "Qty (L)", width: 60, align: "right", format: (v: number) => formatNumber(v) },
];
if (input.includePricing) {
columns.push(
{ header: "Price/L", width: 70, align: "right", format: (v: number) => formatCurrency(v) },
{ header: "Total", width: 80, align: "right", format: (v: number) => formatCurrency(v) },
);
}
const rows = input.notes.map((n) => {
const row: any[] = [n.date, n.number, n.vehicle_registration || "—", n.qty_litres_supplied];
if (input.includePricing) {
row.push(n.price_per_litre, n.total_value);
}
return row;
});
drawTable(ctx, columns, rows);
// Totals row — single line, right-aligned to match the last column
if (input.notes.length > 0) {
moveDown(ctx, 6);
const lineY = ctx.y + 4;
ctx.page.drawLine({ start: { x: 40, y: lineY }, end: { x: 555, y: lineY }, thickness: 0.5, color: COLORS.border });
moveDown(ctx, 12);
drawText(ctx, "Total", { x: 40, size: 10, bold: true });
const tableRight = 40 + columns.reduce((a, c) => a + c.width, 0);
const totalQtyStr = formatNumber(totalQty) + " L";
const qtyW = ctx.font.widthOfTextAtSize(totalQtyStr, 10);
const qtyColStart = 40 + columns.slice(0, 3).reduce((a, c) => a + c.width, 0);
drawText(ctx, totalQtyStr, { x: qtyColStart + columns[3].width - 8 - qtyW, size: 10, bold: true });
if (input.includePricing) {
const totalValStr = formatCurrency(totalValue);
const valW = ctx.font.widthOfTextAtSize(totalValStr, 10);
drawText(ctx, totalValStr, { x: tableRight - 8 - valW, size: 10, bold: true });
}
}
// Footer message
moveDown(ctx, 30);
drawText(ctx, "Thank you for your business.", { size: 9, color: COLORS.muted });
return finalizeWithPageNumbers(ctx);
}
async function finalizeWithPageNumbers(ctx: PDFContext): Promise<Uint8Array> {
const pages = ctx.doc.getPages();
pages.forEach((p, i) => {
const stamp = `Generated ${new Date().toLocaleString("en-ZA")} • 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();
}