diff --git a/src/lib/billing-report-pdf.ts b/src/lib/billing-report-pdf.ts new file mode 100644 index 0000000..14cd027 --- /dev/null +++ b/src/lib/billing-report-pdf.ts @@ -0,0 +1,112 @@ +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; + +export interface TimeReportRow { + work_date: string; + case_label: string; + user_name: string; + description: string; + hours: number; + rate: number; + amount: number; + billable: boolean; +} + +export interface ExpenseReportRow { + expense_date: string; + case_label: string; + user_name: string; + description: string; + amount: number; + billable: boolean; +} + +interface BaseOpts { + clientName: string; + fromDate?: string; + toDate?: string; +} + +function fmtDate(iso?: string | null) { + if (!iso) return "—"; + const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) { + const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); + return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }); + } + return new Date(iso).toLocaleDateString("en-US"); +} + +function fmtCurrency(n: number) { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0); +} + +function header(doc: jsPDF, title: string, opts: BaseOpts) { + const margin = 54; + doc.setFont("times", "bold"); + doc.setFontSize(18); + doc.text(title, margin, margin); + doc.setFont("times", "normal"); + doc.setFontSize(11); + doc.setTextColor(90); + doc.text(opts.clientName, margin, margin + 22); + if (opts.fromDate || opts.toDate) { + const range = `${opts.fromDate ? fmtDate(opts.fromDate) : "—"} to ${opts.toDate ? fmtDate(opts.toDate) : "—"}`; + doc.text(range, margin, margin + 38); + } + doc.setTextColor(0); +} + +export function generateTimeReportPdf(opts: BaseOpts & { rows: TimeReportRow[] }): jsPDF { + const doc = new jsPDF({ unit: "pt", format: "letter" }); + header(doc, "Time Report", opts); + const totalHours = opts.rows.reduce((a, r) => a + (r.hours || 0), 0); + const totalAmt = opts.rows.reduce((a, r) => a + (r.amount || 0), 0); + autoTable(doc, { + startY: 110, + head: [["Date", "Case", "User", "Description", "Hours", "Rate", "Amount"]], + body: opts.rows.map((r) => [ + fmtDate(r.work_date), + r.case_label, + r.user_name, + r.description, + r.hours.toFixed(2), + fmtCurrency(r.rate), + fmtCurrency(r.amount), + ]), + foot: [["", "", "", "Totals", totalHours.toFixed(2), "", fmtCurrency(totalAmt)]], + styles: { font: "times", fontSize: 9, cellPadding: 4 }, + headStyles: { fillColor: [240, 240, 240], textColor: 20 }, + footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" }, + columnStyles: { + 4: { halign: "right" }, + 5: { halign: "right" }, + 6: { halign: "right" }, + }, + }); + return doc; +} + +export function generateExpenseReportPdf(opts: BaseOpts & { rows: ExpenseReportRow[] }): jsPDF { + const doc = new jsPDF({ unit: "pt", format: "letter" }); + header(doc, "Expense Report", opts); + const totalAmt = opts.rows.reduce((a, r) => a + (r.amount || 0), 0); + autoTable(doc, { + startY: 110, + head: [["Date", "Case", "User", "Description", "Billable", "Amount"]], + body: opts.rows.map((r) => [ + fmtDate(r.expense_date), + r.case_label, + r.user_name, + r.description, + r.billable ? "Yes" : "No", + fmtCurrency(r.amount), + ]), + foot: [["", "", "", "", "Total", fmtCurrency(totalAmt)]], + styles: { font: "times", fontSize: 9, cellPadding: 4 }, + headStyles: { fillColor: [240, 240, 240], textColor: 20 }, + footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" }, + columnStyles: { 5: { halign: "right" } }, + }); + return doc; +}