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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:52:43 -04:00
parent cf926c04fa
commit 7b54ddd40d
2 changed files with 241 additions and 4 deletions
@@ -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,10 +172,29 @@ export default function AccountingDashboardPage({ association }: { association?:
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">{associationName}</p>
</div>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={!data || exporting}
onClick={async () => {
if (!data) return;
setExporting(true);
try {
await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data });
} finally {
setExporting(false);
}
}}
>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />} Export PDF
</Button>
</div>
{/* Cash flow summary */}
<div className="grid gap-4 md:grid-cols-3">
+206
View File
@@ -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`);
}