-
Dashboard
-
{associationName}
+
+
+
Dashboard
+
{associationName}
+
+
{/* Cash flow summary */}
diff --git a/src/pages/accounting/lib/dashboardPdf.ts b/src/pages/accounting/lib/dashboardPdf.ts
new file mode 100644
index 0000000..4207c67
--- /dev/null
+++ b/src/pages/accounting/lib/dashboardPdf.ts
@@ -0,0 +1,206 @@
+import jsPDF from "jspdf";
+import autoTable from "jspdf-autotable";
+import { money, fmtDate } from "./format";
+import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "./reportHeader";
+
+type RGB = [number, number, number];
+const TEXT: RGB = [33, 37, 41];
+const MUTED: RGB = [110, 116, 122];
+const INCOME: RGB = [0, 137, 123]; // teal
+const EXPENSE: RGB = [245, 159, 11]; // amber
+const DONUT: RGB[] = [
+ [0, 137, 123], [38, 166, 154], [245, 159, 11], [239, 68, 68], [99, 102, 241], [139, 92, 246],
+];
+
+export type DashboardPdfData = {
+ totalReceivable: number;
+ totalPayable: number;
+ cash: number;
+ counts: { draft: number; sent: number; overdue: number; paid: number };
+ months: { label: string; income: number; expense: number }[];
+ topExpenses: { name: string; value: number }[];
+ recent: { date?: string; description?: string; category?: string | null; reference?: string | null; amount: number; type?: string }[];
+};
+
+const kFmt = (n: number) => (Math.abs(n) >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${Math.round(n)}`);
+
+/** Native (vector) grouped bar chart: income vs expense per month. */
+function drawBarChart(doc: jsPDF, x: number, y: number, w: number, h: number, months: DashboardPdfData["months"], currency: string) {
+ const plotTop = y + 8;
+ const plotH = h - 24;
+ const baseY = plotTop + plotH;
+ const max = Math.max(1, ...months.flatMap((m) => [m.income, m.expense]));
+
+ // gridlines + y labels (0, half, max)
+ doc.setDrawColor(230); doc.setLineWidth(0.4);
+ doc.setFontSize(7); doc.setTextColor(...MUTED);
+ [0, 0.5, 1].forEach((f) => {
+ const gy = baseY - plotH * f;
+ doc.line(x, gy, x + w, gy);
+ doc.text(kFmt(max * f), x - 4, gy + 2, { align: "right" });
+ });
+
+ const n = Math.max(1, months.length);
+ const groupW = w / n;
+ const barW = Math.min(16, groupW * 0.3);
+ months.forEach((m, i) => {
+ const gx = x + i * groupW + groupW / 2;
+ const ih = (m.income / max) * plotH;
+ const eh = (m.expense / max) * plotH;
+ doc.setFillColor(...INCOME); doc.rect(gx - barW - 1.5, baseY - ih, barW, ih, "F");
+ doc.setFillColor(...EXPENSE); doc.rect(gx + 1.5, baseY - eh, barW, eh, "F");
+ doc.setFontSize(7.5); doc.setTextColor(...MUTED);
+ doc.text(m.label, gx, baseY + 11, { align: "center" });
+ });
+
+ // legend
+ let lx = x;
+ const ly = baseY + 24;
+ const legend: [string, RGB][] = [["Income", INCOME], ["Expenses", EXPENSE]];
+ doc.setFontSize(8);
+ legend.forEach(([label, color]) => {
+ doc.setFillColor(...color); doc.rect(lx, ly - 6, 8, 8, "F");
+ doc.setTextColor(...TEXT); doc.text(label, lx + 12, ly, { baseline: "alphabetic" });
+ lx += 14 + doc.getTextWidth(label) + 18;
+ });
+}
+
+/** Native (vector) donut chart + legend for top expense categories. */
+function drawDonut(doc: jsPDF, cx: number, cy: number, r: number, items: DashboardPdfData["topExpenses"], currency: string) {
+ const total = items.reduce((s, it) => s + Math.abs(it.value), 0);
+ if (total <= 0) {
+ doc.setFontSize(9); doc.setTextColor(...MUTED);
+ doc.text("No expense data", cx, cy, { align: "center" });
+ return;
+ }
+ let a = -Math.PI / 2; // start at top
+ items.forEach((it, idx) => {
+ const frac = Math.abs(it.value) / total;
+ const a1 = a + frac * Math.PI * 2;
+ const seg = Math.max(2, Math.ceil(frac * 80));
+ const pts: [number, number][] = [[cx, cy]];
+ for (let i = 0; i <= seg; i++) {
+ const ang = a + (a1 - a) * (i / seg);
+ pts.push([cx + r * Math.cos(ang), cy + r * Math.sin(ang)]);
+ }
+ const deltas = pts.slice(1).map((p, i) => [p[0] - pts[i][0], p[1] - pts[i][1]] as [number, number]);
+ doc.setFillColor(...DONUT[idx % DONUT.length]);
+ doc.lines(deltas, pts[0][0], pts[0][1], [1, 1], "F", true);
+ a = a1;
+ });
+ // hollow center → donut
+ doc.setFillColor(255, 255, 255);
+ doc.circle(cx, cy, r * 0.55, "F");
+
+ // legend below
+ let ly = cy + r + 16;
+ doc.setFontSize(7.5);
+ items.forEach((it, idx) => {
+ doc.setFillColor(...DONUT[idx % DONUT.length]); doc.rect(cx - r, ly - 6, 7, 7, "F");
+ doc.setTextColor(...TEXT);
+ const label = it.name.length > 26 ? it.name.slice(0, 25) + "…" : it.name;
+ doc.text(`${label} ${money(it.value, currency)}`, cx - r + 11, ly);
+ ly += 12;
+ });
+}
+
+export async function generateDashboardPdf(opts: {
+ companyName: string;
+ logoUrl?: string | null;
+ currency?: string;
+ data: DashboardPdfData;
+}) {
+ const { companyName, data } = opts;
+ const currency = opts.currency || "USD";
+ const doc = new jsPDF({ unit: "pt", format: "letter" });
+ const W = doc.internal.pageSize.getWidth();
+ const ML = 40;
+ const contentW = W - ML * 2;
+
+ const logo = await loadBrandedLogo(opts.logoUrl);
+ let y = drawBrandedHeader(doc, {
+ logo,
+ title: "Accounting Dashboard",
+ metaLines: [{ label: "Properties:", value: companyName }],
+ });
+
+ // ── Summary cards ──
+ const cards: [string, number][] = [
+ ["Total Receivables", data.totalReceivable],
+ ["Total Payables", data.totalPayable],
+ ["Bank Balance", data.cash],
+ ];
+ const gap = 12;
+ const cardW = (contentW - gap * 2) / 3;
+ const cardH = 48;
+ cards.forEach(([label, val], i) => {
+ const cx = ML + i * (cardW + gap);
+ doc.setDrawColor(220); doc.setFillColor(249, 250, 251); doc.setLineWidth(0.5);
+ doc.roundedRect(cx, y, cardW, cardH, 4, 4, "FD");
+ doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...MUTED);
+ doc.text(label.toUpperCase(), cx + 10, y + 16);
+ doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(...TEXT);
+ doc.text(money(val, currency), cx + 10, y + 36);
+ });
+ y += cardH + 28;
+
+ // ── Charts row: bar chart (left) + donut (right) ──
+ doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(...TEXT);
+ doc.text("Income & Expenses", ML, y);
+ doc.text("Top Expenses", ML + contentW * 0.62 + 10, y);
+ doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
+ doc.text("Last 6 months", ML, y + 12);
+ doc.text("By category", ML + contentW * 0.62 + 10, y + 12);
+ y += 24;
+
+ const barW = contentW * 0.55;
+ drawBarChart(doc, ML + 26, y, barW, 150, data.months, currency);
+ const donutCx = ML + contentW * 0.62 + 10 + (contentW * 0.34) / 2;
+ drawDonut(doc, donutCx, y + 60, 52, data.topExpenses, currency);
+
+ // advance past the taller of the two blocks (chart ~150 + legend, donut + legend)
+ y += 150 + 30 + Math.max(0, (data.topExpenses.length - 3)) * 12;
+
+ // ── Invoices overview ──
+ doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(...TEXT);
+ doc.text("Invoices Overview", ML, y);
+ y += 12;
+ const tiles: [string, number][] = [
+ ["Draft", data.counts.draft], ["Sent", data.counts.sent],
+ ["Overdue", data.counts.overdue], ["Paid", data.counts.paid],
+ ];
+ const tileW = (contentW - gap * 3) / 4;
+ tiles.forEach(([label, val], i) => {
+ const tx = ML + i * (tileW + gap);
+ doc.setDrawColor(220); doc.setFillColor(255, 255, 255); doc.setLineWidth(0.5);
+ doc.roundedRect(tx, y, tileW, 34, 4, 4, "FD");
+ doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
+ doc.text(label, tx + 8, y + 14);
+ doc.setFont("helvetica", "bold"); doc.setFontSize(13); doc.setTextColor(...TEXT);
+ doc.text(String(val), tx + 8, y + 28);
+ });
+ y += 34 + 24;
+
+ // ── Recent transactions ──
+ doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(...TEXT);
+ doc.text("Recent Transactions", ML, y);
+ y += 6;
+ autoTable(doc, {
+ startY: y + 4,
+ margin: { left: ML, right: ML },
+ head: [["Date", "Description", "Category", "Reference", "Amount"]],
+ body: (data.recent ?? []).map((t) => [
+ t.date ? fmtDate(t.date) : "",
+ t.description ?? "",
+ t.category ?? "—",
+ t.reference ?? "—",
+ `${t.type === "credit" ? "+" : "−"}${money(Math.abs(Number(t.amount || 0)), currency)}`,
+ ]),
+ styles: { font: "helvetica", fontSize: 8, textColor: TEXT, lineColor: [225, 228, 232], lineWidth: 0.1 },
+ headStyles: { fillColor: [237, 239, 242], textColor: TEXT, fontStyle: "bold" },
+ columnStyles: { 4: { halign: "right" } },
+ });
+
+ drawBrandedFooter(doc);
+ doc.save(`accounting-dashboard-${new Date().toISOString().slice(0, 10)}.pdf`);
+}