Files
acmcc/src/pages/accounting/AccountingDashboardPage.tsx
T
admin 7b54ddd40d 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>
2026-06-02 18:52:43 -04:00

357 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, FileDown,
} from "lucide-react";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell, Legend,
} from "recharts";
const DONUT_COLORS = ["#00897B", "#26A69A", "#F59E0B", "#EF4444", "#6366F1", "#8B5CF6"];
export default function AccountingDashboardPage({ association }: { association?: { id: string; name?: string } | null } = {}) {
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId(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,
queryFn: async () => {
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5);
sixMonthsAgo.setDate(1);
const sinceIso = sixMonthsAgo.toISOString().slice(0, 10);
const [inv, bills, tx, acc] = await Promise.all([
accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid),
accounting.from("bills").select("total,status,issue_date").eq("company_id", cid),
accounting
.from("transactions")
.select("amount,type,date,description,category,reference")
.eq("company_id", cid)
.order("date", { ascending: false })
.limit(10),
accounting.from("accounts").select("balance,is_bank").eq("company_id", cid),
]);
const invoices = inv.data ?? [];
const billsArr = bills.data ?? [];
const txAll = tx.data ?? [];
const totalReceivable = invoices
.filter((i) => i.status !== "paid" && i.status !== "void")
.reduce((s, i) => s + Number(i.total), 0);
const totalPayable = billsArr
.filter((b) => b.status !== "paid" && b.status !== "void")
.reduce((s, b) => s + Number(b.total), 0);
const cash = (acc.data ?? [])
.filter((a) => a.is_bank)
.reduce((s, a) => s + Number(a.balance), 0);
// Invoice status counts
const counts = { draft: 0, sent: 0, overdue: 0, paid: 0 };
for (const i of invoices) {
if (i.status === "paid") counts.paid++;
else if (i.status === "draft") counts.draft++;
else if (i.status === "void") continue;
else {
if ((i as any).status === "overdue") counts.overdue++;
else counts.sent++;
}
}
// 6 month income/expense
const months: { key: string; label: string; income: number; expense: number }[] = [];
const now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({
key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
label: d.toLocaleString("en-US", { month: "short" }),
income: 0,
expense: 0,
});
}
const bucket = (date: string) => date.slice(0, 7);
for (const i of invoices) {
if (i.issue_date && i.issue_date >= sinceIso) {
const m = months.find((x) => x.key === bucket(i.issue_date));
if (m) m.income += Number(i.total);
}
}
for (const b of billsArr) {
if (b.issue_date && b.issue_date >= sinceIso) {
const m = months.find((x) => x.key === bucket(b.issue_date));
if (m) m.expense += Number(b.total);
}
}
// Top expenses by category (from debit transactions)
const catMap = new Map<string, number>();
for (const t of txAll) {
if (t.type === "debit") {
const key = t.category || "Uncategorized";
catMap.set(key, (catMap.get(key) ?? 0) + Number(t.amount));
}
}
const topExpenses = Array.from(catMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6);
return {
totalReceivable,
totalPayable,
cash,
counts,
months,
topExpenses,
recent: txAll,
};
},
});
const summary = [
{
label: "Total Receivables",
value: money(data?.totalReceivable, c),
icon: FileText,
accent: "bg-blue-50 text-blue-600 ring-blue-100",
bar: "from-blue-500/10 to-transparent",
trend: "Outstanding",
TrendIcon: ArrowUpRight,
},
{
label: "Total Payables",
value: money(data?.totalPayable, c),
icon: Receipt,
accent: "bg-orange-50 text-orange-600 ring-orange-100",
bar: "from-orange-500/10 to-transparent",
trend: "Owed",
TrendIcon: ArrowDownRight,
},
{
label: "Bank Balance",
value: money(data?.cash, c),
icon: Landmark,
accent: "bg-emerald-50 text-emerald-600 ring-emerald-100",
bar: "from-emerald-500/10 to-transparent",
trend: "All accounts",
TrendIcon: ArrowUpRight,
},
];
const invoiceTiles = [
{ label: "Draft", value: data?.counts.draft ?? 0, dot: "bg-slate-400", badge: "bg-slate-100 text-slate-700" },
{ label: "Sent", value: data?.counts.sent ?? 0, dot: "bg-blue-500", badge: "bg-blue-100 text-blue-700" },
{ label: "Overdue", value: data?.counts.overdue ?? 0, dot: "bg-red-500", badge: "bg-red-100 text-red-700" },
{ label: "Paid", value: data?.counts.paid ?? 0, dot: "bg-emerald-500", badge: "bg-emerald-100 text-emerald-700" },
];
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
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">
{summary.map((s) => (
<Card key={s.label} className="relative overflow-hidden border-border/60 shadow-sm">
<div className={`pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b ${s.bar}`} />
<CardContent className="relative p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">{s.label}</p>
<p className="mt-2 text-2xl font-semibold tracking-tight">{s.value}</p>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ring-1 ${s.accent}`}>
<s.icon className="h-5 w-5" />
</div>
</div>
<div className="mt-4 flex items-center gap-1 text-xs text-muted-foreground">
<s.TrendIcon className="h-3.5 w-3.5" />
{s.trend}
</div>
</CardContent>
</Card>
))}
</div>
{/* Charts row */}
<div className="grid gap-4 lg:grid-cols-3">
<Card className="border-border/60 shadow-sm lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Income & Expenses</CardTitle>
<p className="text-xs text-muted-foreground">Last 6 months</p>
</CardHeader>
<CardContent className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data?.months ?? []} margin={{ top: 8, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} fontSize={12} stroke="#6B7280" />
<YAxis tickLine={false} axisLine={false} fontSize={12} stroke="#6B7280" />
<Tooltip
cursor={{ fill: "rgba(0,137,123,0.06)" }}
contentStyle={{ borderRadius: 8, border: "1px solid #E5E7EB", fontSize: 12 }}
formatter={(v: number) => money(v, c)}
/>
<Legend wrapperStyle={{ fontSize: 12 }} iconType="circle" />
<Bar dataKey="income" name="Income" fill="#00897B" radius={[4, 4, 0, 0]} />
<Bar dataKey="expense" name="Expenses" fill="#F59E0B" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Top Expenses</CardTitle>
<p className="text-xs text-muted-foreground">By category</p>
</CardHeader>
<CardContent className="h-72">
{(data?.topExpenses?.length ?? 0) === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No expenses yet
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data!.topExpenses}
dataKey="value"
nameKey="name"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
>
{data!.topExpenses.map((_, i) => (
<Cell key={i} fill={DONUT_COLORS[i % DONUT_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ borderRadius: 8, border: "1px solid #E5E7EB", fontSize: 12 }}
formatter={(v: number) => money(v, c)}
/>
<Legend wrapperStyle={{ fontSize: 11 }} iconType="circle" />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{/* Invoices overview */}
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Invoices Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{invoiceTiles.map((t) => (
<div
key={t.label}
className="flex items-center justify-between rounded-lg border border-border/60 bg-background p-4"
>
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${t.dot}`} />
<span className="text-sm font-medium">{t.label}</span>
</div>
<Badge variant="secondary" className={`${t.badge} border-0 font-semibold`}>
{t.value}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Recent Transactions */}
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Recent Transactions</CardTitle>
</CardHeader>
<CardContent className="p-0">
{(data?.recent?.length ?? 0) === 0 ? (
<p className="px-6 py-8 text-sm text-muted-foreground">No transactions yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-6 py-3 text-left font-medium">Date</th>
<th className="px-6 py-3 text-left font-medium">Description</th>
<th className="px-6 py-3 text-left font-medium">Category</th>
<th className="px-6 py-3 text-left font-medium">Reference</th>
<th className="px-6 py-3 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{data!.recent.map((t: any, i: number) => (
<tr key={i} className="hover:bg-muted/30">
<td className="px-6 py-3 text-muted-foreground">{fmtDate(t.date)}</td>
<td className="px-6 py-3 font-medium">{t.description}</td>
<td className="px-6 py-3 text-muted-foreground">{t.category ?? "—"}</td>
<td className="px-6 py-3 text-muted-foreground">{t.reference ?? "—"}</td>
<td
className={`px-6 py-3 text-right font-semibold ${
t.type === "credit" ? "text-emerald-600" : "text-red-600"
}`}
>
{t.type === "credit" ? "+" : ""}
{money(t.amount, c)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}