import { newPDF, drawHeader, drawText, drawTable, moveDown, ensureSpace, COLORS, type PDFContext, } from "./shared"; import { formatCurrency, formatDate, formatNumber } from "../utils"; import type { Customer, DeliveryNote, FuelPurchase, Supplier } from "../types"; export type ReportType = | "customer-delivery" | "vehicle-delivery" | "daily-summary" | "detailed-delivery" | "tank-delivery" | "purchase-summary" | "inventory"; export const REPORT_TYPES: { value: ReportType; label: string }[] = [ { value: "customer-delivery", label: "Customer Delivery" }, { value: "vehicle-delivery", label: "Vehicle Delivery" }, { value: "daily-summary", label: "Daily Summary" }, { value: "detailed-delivery", label: "Detailed Delivery" }, { value: "tank-delivery", label: "Tank Delivery" }, { value: "purchase-summary", label: "Purchase Summary" }, { value: "inventory", label: "Inventory" }, ]; export interface ReportContext { startDate: string; endDate: string; includePricing: boolean; notes: DeliveryNote[]; purchases: FuelPurchase[]; customers: Customer[]; suppliers: Supplier[]; } function finalize(ctx: PDFContext): Promise { 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(); } function groupBy(arr: T[], key: (t: T) => K): Map { const out = new Map(); for (const t of arr) { const k = key(t); if (!out.has(k)) out.set(k, []); out.get(k)!.push(t); } return out; } export async function generateReport(type: ReportType, ctx: ReportContext): Promise { switch (type) { case "customer-delivery": return customerDelivery(ctx); case "vehicle-delivery": return vehicleDelivery(ctx); case "daily-summary": return dailySummary(ctx); case "detailed-delivery": return detailedDelivery(ctx); case "tank-delivery": return tankDelivery(ctx); case "purchase-summary": return purchaseSummary(ctx); case "inventory": return inventory(ctx); } } async function customerDelivery(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Customer Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const customers = new Map(ctx.customers.map((c) => [c.id, c])); const grouped = groupBy(ctx.notes, (n) => n.customer); for (const [customerId, notes] of grouped) { // Pass the full "Customer Delivery Report (continued)" as the header // title — drawHeader will treat it as the title (no subtitle needed // since ensureSpace only forwards a single string). ensureSpace(pdf, 120, "Customer Delivery Report (continued)"); const customer = customers.get(customerId); drawText(pdf, customer?.name || customerId, { size: 12, bold: true }); moveDown(pdf, 14); const totalQty = notes.reduce((a, n) => a + n.qty_litres_supplied, 0); const totalValue = notes.reduce((a, n) => a + (n.total_value || 0), 0); const cols = [ { header: "Date", width: 80, format: (v: string) => formatDate(v) }, { header: "Note #", width: 90 }, { header: "Vehicle", width: 110 }, { header: "Qty (L)", width: 70, align: "right" as const, format: (v: number) => formatNumber(v) }, ]; if (ctx.includePricing) { cols.push( { header: "Total", width: 100, align: "right" as const, format: (v: number) => formatCurrency(v) }, ); } const rows = notes.map((n) => { const r: any[] = [n.date, n.number, n.vehicle_registration || "—", n.qty_litres_supplied]; if (ctx.includePricing) r.push(n.total_value); return r; }); drawTable(pdf, cols, rows); moveDown(pdf, 10); // Subtotal on a single line, right-aligned to the last column. // The qty and value are separated by a wide gap so the "L" (litres) // and "R" (Rand) don't visually run into each other. const subtotalRight = 40 + cols.reduce((a, c) => a + c.width, 0); if (ctx.includePricing) { const totalValStr = formatCurrency(totalValue); const valW = pdf.font.widthOfTextAtSize(totalValStr, 10); const subtotalStr = `Subtotal: ${formatNumber(totalQty)} L`; const subW = pdf.font.widthOfTextAtSize(subtotalStr, 10); drawText(pdf, subtotalStr, { x: subtotalRight - valW - 32 - subW, y: pdf.y, size: 10, bold: true }); drawText(pdf, totalValStr, { x: subtotalRight - 8 - valW, y: pdf.y, size: 10, bold: true }); } else { const subtotalStr = `Subtotal: ${formatNumber(totalQty)} L`; const subW = pdf.font.widthOfTextAtSize(subtotalStr, 10); const qtyColStart = 40 + cols.slice(0, 3).reduce((a, c) => a + c.width, 0); drawText(pdf, subtotalStr, { x: qtyColStart + cols[3].width - 8 - subW, y: pdf.y, size: 10, bold: true }); } moveDown(pdf, 24); } return finalize(pdf); } async function vehicleDelivery(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Vehicle Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const grouped = groupBy(ctx.notes, (n) => n.vehicle_registration || "(none)"); for (const [vehicle, notes] of grouped) { ensureSpace(pdf, 120, "Vehicle Delivery Report (continued)"); drawText(pdf, vehicle, { size: 12, bold: true }); moveDown(pdf, 14); const cols = [ { header: "Date", width: 80, format: (v: string) => formatDate(v) }, { header: "Note #", width: 90 }, { header: "Customer", width: 200 }, { header: "Qty (L)", width: 70, align: "right" as const, format: (v: number) => formatNumber(v) }, ]; const customers = new Map(ctx.customers.map((c) => [c.id, c])); const rows = notes.map((n) => [n.date, n.number, customers.get(n.customer)?.name || n.customer, n.qty_litres_supplied]); drawTable(pdf, cols, rows); moveDown(pdf, 16); } return finalize(pdf); } async function dailySummary(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Daily Summary", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const grouped = groupBy(ctx.notes, (n) => n.date); const cols = [ { header: "Date", width: 120, format: (v: string) => formatDate(v) }, { header: "Notes", width: 80, align: "right" as const }, { header: "Total Qty (L)", width: 120, align: "right" as const, format: (v: number) => formatNumber(v) }, ]; if (ctx.includePricing) cols.push({ header: "Total Value", width: 120, align: "right" as const, format: (v: number) => formatCurrency(v) }); const rows = Array.from(grouped.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, notes]) => { const qty = notes.reduce((a, n) => a + n.qty_litres_supplied, 0); const val = notes.reduce((a, n) => a + (n.total_value || 0), 0); const r: any[] = [date, String(notes.length), qty]; if (ctx.includePricing) r.push(val); return r; }); drawTable(pdf, cols, rows); return finalize(pdf); } async function detailedDelivery(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Detailed Delivery", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const customers = new Map(ctx.customers.map((c) => [c.id, c])); const cols = [ { header: "Date", width: 70, format: (v: string) => formatDate(v) }, { header: "Note #", width: 70 }, { header: "Customer", width: 130 }, { header: "Vehicle", width: 80 }, { header: "Qty (L)", width: 60, align: "right" as const, format: (v: number) => formatNumber(v) }, ]; if (ctx.includePricing) { cols.push( { header: "Price/L", width: 60, align: "right" as const, format: (v: number) => formatCurrency(v) }, { header: "Total", width: 80, align: "right" as const, format: (v: number) => formatCurrency(v) }, ); } const rows = ctx.notes.map((n) => { const r: any[] = [n.date, n.number, customers.get(n.customer)?.name || n.customer, n.vehicle_registration || "—", n.qty_litres_supplied]; if (ctx.includePricing) r.push(n.price_per_litre, n.total_value); return r; }); drawTable(pdf, cols, rows); return finalize(pdf); } async function tankDelivery(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Tank Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const grouped = groupBy(ctx.notes, (n) => n.plant_dispensing || "(unspecified)"); for (const [tank, notes] of grouped) { ensureSpace(pdf, 120, "Tank Delivery Report (continued)"); drawText(pdf, tank, { size: 12, bold: true }); moveDown(pdf, 14); const customers = new Map(ctx.customers.map((c) => [c.id, c])); const cols = [ { header: "Date", width: 80, format: (v: string) => formatDate(v) }, { header: "Note #", width: 80 }, { header: "Customer", width: 180 }, { header: "Vehicle", width: 80 }, { header: "Qty (L)", width: 80, align: "right" as const, format: (v: number) => formatNumber(v) }, ]; const rows = notes.map((n) => [n.date, n.number, customers.get(n.customer)?.name || n.customer, n.vehicle_registration || "—", n.qty_litres_supplied]); drawTable(pdf, cols, rows); moveDown(pdf, 16); } return finalize(pdf); } async function purchaseSummary(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Purchase Summary", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const suppliers = new Map(ctx.suppliers.map((s) => [s.id, s])); const cols = [ { header: "Month", width: 100 }, { header: "Supplier", width: 160 }, { header: "Records", width: 60, align: "right" as const }, { header: "Total Qty (L)", width: 100, align: "right" as const, format: (v: number) => formatNumber(v) }, { header: "Total Cost", width: 120, align: "right" as const, format: (v: number) => formatCurrency(v) }, ]; const grouped = groupBy(ctx.purchases, (p) => p.purchase_date.slice(0, 7)); const rows: any[][] = []; for (const [month, purchases] of grouped) { let monthTotal = 0; let monthQty = 0; for (const p of purchases) { const sup = suppliers.get(p.supplier); rows.push([month, sup?.name || p.supplier, "1", p.purchase_qty, p.purchase_qty * p.purchase_price]); monthTotal += p.purchase_qty * p.purchase_price; monthQty += p.purchase_qty; } rows.push([`${month} total`, "", String(purchases.length), monthQty, monthTotal]); } drawTable(pdf, cols, rows); return finalize(pdf); } async function inventory(ctx: ReportContext): Promise { const pdf = await newPDF(); drawHeader(pdf, "Inventory Balance", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); const totalPurchased = ctx.purchases.reduce((a, p) => a + p.purchase_qty, 0); const purchaseValue = ctx.purchases.reduce((a, p) => a + p.purchase_qty * p.purchase_price, 0); const totalDelivered = ctx.notes.reduce((a, n) => a + n.qty_litres_supplied, 0); const deliveryValue = ctx.notes.reduce((a, n) => a + (n.total_value || 0), 0); const balance = totalPurchased - totalDelivered; const margin = deliveryValue - (totalDelivered * (totalPurchased > 0 ? purchaseValue / totalPurchased : 0)); const cols = [ { header: "Metric", width: 220 }, { header: "Value", width: 200, align: "right" as const }, ]; const rows: any[][] = [ ["Total fuel purchased", `${formatNumber(totalPurchased)} L`], ["Total purchase cost", formatCurrency(purchaseValue)], ["Total fuel delivered", `${formatNumber(totalDelivered)} L`], ["Total delivery value", formatCurrency(deliveryValue)], ["Inventory balance (L)", `${formatNumber(balance)} L`], ["Estimated margin", formatCurrency(margin)], ]; drawTable(pdf, cols, rows); return finalize(pdf); }