From 7b54ddd40de221b9ef21aac31a638c5151c04d84 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 2 Jun 2026 18:52:43 -0400 Subject: [PATCH] Export the Accounting Dashboard as a vector PDF Add a dashboard PDF generator that renders the metrics, an Income & Expenses bar chart, a Top Expenses donut, the invoices overview, and recent transactions as native PDF vector graphics + selectable text (not a screenshot), using the shared branded header/footer. Wire an "Export PDF" button into the dashboard header (association logo, ACM fallback). Co-Authored-By: Claude Opus 4.8 --- .../accounting/AccountingDashboardPage.tsx | 39 +++- src/pages/accounting/lib/dashboardPdf.ts | 206 ++++++++++++++++++ 2 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 src/pages/accounting/lib/dashboardPdf.ts diff --git a/src/pages/accounting/AccountingDashboardPage.tsx b/src/pages/accounting/AccountingDashboardPage.tsx index 4bc76b2..093573d 100644 --- a/src/pages/accounting/AccountingDashboardPage.tsx +++ b/src/pages/accounting/AccountingDashboardPage.tsx @@ -1,11 +1,15 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { accounting } from "@/lib/accountingClient"; +import { supabase } from "@/integrations/supabase/client"; import { useCompanyId } from "./lib/useCompanyId"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { money, fmtDate } from "./lib/format"; +import { generateDashboardPdf } from "./lib/dashboardPdf"; import { - Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, + Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown, } from "lucide-react"; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, @@ -19,6 +23,14 @@ export default function AccountingDashboardPage({ association }: { association?: const cid = companyId ?? ""; const c = "USD"; + const { data: assocMeta } = useQuery({ + queryKey: ["assoc-logo", associationId], + enabled: !!associationId, + queryFn: async () => (await supabase.from("associations").select("logo_url").eq("id", associationId!).maybeSingle()).data, + }); + const logoUrl = (assocMeta as any)?.logo_url || null; + const [exporting, setExporting] = useState(false); + const { data } = useQuery({ queryKey: ["dashboard", cid], enabled: !!cid, @@ -160,9 +172,28 @@ export default function AccountingDashboardPage({ association }: { association?: return (
-
-

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`); +}