mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user