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 { useQuery } from "@tanstack/react-query";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { money, fmtDate } from "./lib/format";
|
import { money, fmtDate } from "./lib/format";
|
||||||
|
import { generateDashboardPdf } from "./lib/dashboardPdf";
|
||||||
import {
|
import {
|
||||||
Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2,
|
Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
@@ -19,6 +23,14 @@ export default function AccountingDashboardPage({ association }: { association?:
|
|||||||
const cid = companyId ?? "";
|
const cid = companyId ?? "";
|
||||||
const c = "USD";
|
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({
|
const { data } = useQuery({
|
||||||
queryKey: ["dashboard", cid],
|
queryKey: ["dashboard", cid],
|
||||||
enabled: !!cid,
|
enabled: !!cid,
|
||||||
@@ -160,10 +172,29 @@ export default function AccountingDashboardPage({ association }: { association?:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
<p className="text-sm text-muted-foreground">{associationName}</p>
|
<p className="text-sm text-muted-foreground">{associationName}</p>
|
||||||
</div>
|
</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 */}
|
{/* Cash flow summary */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<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