127 lines
5.3 KiB
TypeScript
127 lines
5.3 KiB
TypeScript
|
|
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();
|
||
|
|
}
|