mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
7b54ddd40d
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>
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|