RJBULK-APP/lib/pdf/reports.ts
SJCoetz 1efa979443 feat: initial setup of rj bulk supplies backoffice app
Adds complete initial codebase for the RJ Bulk Supplies fuel delivery backoffice:
- Authentication pages and protected route layouts
- Customer, supplier, fuel purchase, and delivery note management
- Custom reporting and customer statement sending tools
- Full UI component library and Tailwind styling
- Server actions, API routes, and PocketBase integration
- Development scripts and project configuration updates
- Updated README and package metadata
2026-07-19 20:38:16 +02:00

279 lines
12 KiB
TypeScript

import {
newPDF,
drawHeader,
drawText,
drawTable,
moveDown,
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<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();
}
function groupBy<T, K extends string | number>(arr: T[], key: (t: T) => K): Map<K, T[]> {
const out = new Map<K, T[]>();
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<Uint8Array> {
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<Uint8Array> {
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) {
if (pdf.y < 100) drawHeader(pdf, "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, 6);
// Subtotal on a single line, right-aligned to the last column
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 - 12 - 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<Uint8Array> {
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) {
if (pdf.y < 100) drawHeader(pdf, "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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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) {
if (pdf.y < 100) drawHeader(pdf, "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<Uint8Array> {
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<Uint8Array> {
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);
}