mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
e302fb91f0
- Remove the Zoho Books integration (edge functions, sync libs, settings, reports/overview, banking links, fees tab, import dialog); preserve fee rules as a standalone FeesTab and the COA accounting_system classification. - Financial Overview/Reports (staff + board) render the Accounting dashboard and reports; board reports mirror the rich Accounting Reports. - New Reserve Fund Schedule report + an is_reserve flag on accounts. - Unify all report exports to a branded format (logo + centered header + footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs Actuals and Bank Reconciliation PDFs now match the reference layout. - Render financial reports inline (no preview pop-up). - Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA navigation; editable bills in the Accounting Bills page. - Negative opening balances flow through to the GL and reports (allow negative input; keep non-zero on save; signed CSV import). - Upload a per-account trial balance via CSV on Opening Balances. - Board members: read-only RLS access to their association's accounting ledger; editable board-members panel on the association page; share vendor contacts with the board (toggle + directory section). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { accounting } from "@/lib/accountingClient";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react";
|
||
import { fmtDate } from "../lib/format";
|
||
import jsPDF from "jspdf";
|
||
import autoTable from "jspdf-autotable";
|
||
import { ReportSheet } from "./ReportSheet";
|
||
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||
|
||
type Account = {
|
||
id: string;
|
||
name: string;
|
||
code: string | null;
|
||
type: "asset" | "liability" | "equity" | "income" | "expense";
|
||
subtype: string | null;
|
||
balance: number;
|
||
};
|
||
|
||
const TYPE_ORDER: Account["type"][] = ["asset", "liability", "equity", "income", "expense"];
|
||
const TYPE_LABEL: Record<Account["type"], string> = {
|
||
asset: "Assets",
|
||
liability: "Liabilities",
|
||
equity: "Equity",
|
||
income: "Income",
|
||
expense: "Expenses",
|
||
};
|
||
const DEBIT_NATURAL: Account["type"][] = ["asset", "expense"];
|
||
|
||
const TEAL: [number, number, number] = [0, 137, 123];
|
||
|
||
function fmt(n: number): string {
|
||
if (!n) return "";
|
||
const abs = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
return n < 0 ? `(${abs})` : abs;
|
||
}
|
||
|
||
function splitDebitCredit(a: Account): { debit: number; credit: number } {
|
||
const bal = Number(a.balance || 0);
|
||
const naturalDebit = DEBIT_NATURAL.includes(a.type);
|
||
// Positive balance shown on the natural side; negative flips
|
||
if (naturalDebit) {
|
||
return bal >= 0 ? { debit: bal, credit: 0 } : { debit: 0, credit: -bal };
|
||
}
|
||
return bal >= 0 ? { debit: 0, credit: bal } : { debit: -bal, credit: 0 };
|
||
}
|
||
|
||
export function TrialBalanceReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||
const [basis, setBasis] = useState<"accrual" | "cash">("accrual");
|
||
const [showZero, setShowZero] = useState(false);
|
||
const [typeFilter, setTypeFilter] = useState<"all" | Account["type"]>("all");
|
||
|
||
const { data: acctMeta = [], isLoading } = useQuery({
|
||
queryKey: ["tb-accounts", companyId],
|
||
enabled: !!companyId,
|
||
queryFn: async () => {
|
||
const { data } = await accounting
|
||
.from("accounts")
|
||
.select("id,name,code,type,subtype")
|
||
.eq("company_id", companyId)
|
||
.order("code", { ascending: true });
|
||
return (data ?? []) as Omit<Account, "balance">[];
|
||
},
|
||
});
|
||
|
||
// Account balances come from the general ledger as of the report date — the
|
||
// single source shared with the P&L, Balance Sheet, and General Ledger report.
|
||
const { data: glLines = [] } = useQuery({
|
||
queryKey: ["tb-gl", companyId, asOf],
|
||
enabled: !!companyId,
|
||
queryFn: async () => {
|
||
const { data } = await accounting
|
||
.from("journal_entry_lines")
|
||
.select("debit,credit,account_id,journal_entries!inner(company_id,date)")
|
||
.eq("journal_entries.company_id", companyId)
|
||
.lte("journal_entries.date", asOf);
|
||
return data ?? [];
|
||
},
|
||
});
|
||
|
||
const accounts = useMemo<Account[]>(() => {
|
||
const net = new Map<string, number>(); // debit − credit
|
||
for (const l of glLines as any[]) {
|
||
net.set(l.account_id, (net.get(l.account_id) ?? 0) + Number(l.debit || 0) - Number(l.credit || 0));
|
||
}
|
||
return (acctMeta as any[]).map((a) => {
|
||
const n = net.get(a.id) ?? 0;
|
||
// store as the account's natural balance (positive on its normal side)
|
||
const balance = DEBIT_NATURAL.includes(a.type) ? n : -n;
|
||
return { ...a, balance } as Account;
|
||
});
|
||
}, [acctMeta, glLines]);
|
||
|
||
const grouped = useMemo(() => {
|
||
const filtered = accounts.filter((a) => typeFilter === "all" || a.type === typeFilter);
|
||
const map: Record<Account["type"], Account[]> = {
|
||
asset: [], liability: [], equity: [], income: [], expense: [],
|
||
};
|
||
for (const a of filtered) map[a.type].push(a);
|
||
return map;
|
||
}, [accounts, typeFilter]);
|
||
|
||
const totals = useMemo(() => {
|
||
let debit = 0, credit = 0;
|
||
for (const a of accounts) {
|
||
if (typeFilter !== "all" && a.type !== typeFilter) continue;
|
||
const { debit: d, credit: c } = splitDebitCredit(a);
|
||
debit += d; credit += c;
|
||
}
|
||
return { debit, credit };
|
||
}, [accounts, typeFilter]);
|
||
|
||
const diff = totals.debit - totals.credit;
|
||
const inBalance = Math.abs(diff) < 0.005;
|
||
|
||
const exportPDF = async () => {
|
||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||
const W = doc.internal.pageSize.getWidth();
|
||
const ML = 40;
|
||
const logo = await loadBrandedLogo(logoUrl);
|
||
const startY = drawBrandedHeader(doc, {
|
||
logo, title: "Trial Balance",
|
||
subtitle: `As of ${fmtDate(asOf)} · ${basis === "cash" ? "Cash" : "Accrual"} basis`,
|
||
metaLines: [{ label: "Properties:", value: companyName }],
|
||
});
|
||
|
||
// Body rows
|
||
const body: any[] = [];
|
||
for (const t of TYPE_ORDER) {
|
||
const rows = grouped[t];
|
||
if (!rows.length) continue;
|
||
const tDebit = rows.reduce((s, a) => s + splitDebitCredit(a).debit, 0);
|
||
const tCredit = rows.reduce((s, a) => s + splitDebitCredit(a).credit, 0);
|
||
body.push([{ content: TYPE_LABEL[t], colSpan: 2, styles: { fontStyle: "bold", fillColor: [232, 240, 240] } }, { content: fmt(tDebit), styles: { fontStyle: "bold", fillColor: [232, 240, 240], halign: "right" } }, { content: fmt(tCredit), styles: { fontStyle: "bold", fillColor: [232, 240, 240], halign: "right" } }]);
|
||
for (const a of rows) {
|
||
const { debit, credit } = splitDebitCredit(a);
|
||
const zero = debit === 0 && credit === 0;
|
||
if (zero && !showZero) continue;
|
||
body.push([
|
||
a.code ?? "",
|
||
{ content: " " + a.name, styles: zero ? { textColor: [150, 150, 150], fontStyle: "italic" } : {} },
|
||
{ content: fmt(debit), styles: { halign: "right" } },
|
||
{ content: fmt(credit), styles: { halign: "right" } },
|
||
]);
|
||
}
|
||
}
|
||
body.push([
|
||
{ content: "Total", colSpan: 2, styles: { fontStyle: "bold", fillColor: [240, 240, 240], lineWidth: { top: 1.5, bottom: 1.5 } } },
|
||
{ content: fmt(totals.debit), styles: { fontStyle: "bold", halign: "right", fillColor: [240, 240, 240], lineWidth: { top: 1.5, bottom: 1.5 } } },
|
||
{ content: fmt(totals.credit), styles: { fontStyle: "bold", halign: "right", fillColor: [240, 240, 240], lineWidth: { top: 1.5, bottom: 1.5 } } },
|
||
]);
|
||
|
||
autoTable(doc, {
|
||
startY,
|
||
head: [["Code", "Account", "Debit", "Credit"]],
|
||
body,
|
||
styles: { fontSize: 9, cellPadding: 4 },
|
||
headStyles: { fillColor: TEAL, textColor: 255 },
|
||
columnStyles: { 0: { cellWidth: 60 }, 2: { halign: "right" }, 3: { halign: "right" } },
|
||
margin: { left: ML, right: ML },
|
||
});
|
||
|
||
const finalY = (doc as any).lastAutoTable.finalY + 20;
|
||
doc.setFontSize(10); doc.setFont("times", "italic");
|
||
if (inBalance) {
|
||
doc.setTextColor(0, 128, 0);
|
||
doc.text(`Total Debits equal Total Credits — Trial Balance is in balance as of ${fmtDate(asOf)}`, W / 2, finalY, { align: "center" });
|
||
} else {
|
||
doc.setTextColor(185, 28, 28);
|
||
doc.text(`Out of balance by ${fmt(Math.abs(diff))} — check for unposted journal entries or missing opening balances`, W / 2, finalY, { align: "center" });
|
||
}
|
||
|
||
drawBrandedFooter(doc);
|
||
doc.save(`trial-balance-${asOf}.pdf`);
|
||
};
|
||
|
||
const exportCSV = () => {
|
||
const lines = ["Code,Account,Debit,Credit"];
|
||
for (const t of TYPE_ORDER) {
|
||
const rows = grouped[t];
|
||
if (!rows.length) continue;
|
||
lines.push(`,"${TYPE_LABEL[t]}",,`);
|
||
for (const a of rows) {
|
||
const { debit, credit } = splitDebitCredit(a);
|
||
if (debit === 0 && credit === 0 && !showZero) continue;
|
||
lines.push([a.code ?? "", `"${a.name}"`, debit || "", credit || ""].join(","));
|
||
}
|
||
}
|
||
lines.push(`,"Total",${totals.debit.toFixed(2)},${totals.credit.toFixed(2)}`);
|
||
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `trial-balance-${asOf}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||
<div>
|
||
<Label className="text-xs text-muted-foreground">As of Date</Label>
|
||
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value)} className="w-44 mt-1" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-muted-foreground">Basis</Label>
|
||
<Select value={basis} onValueChange={(v) => setBasis(v as any)}>
|
||
<SelectTrigger className="w-32 mt-1"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="accrual">Accrual</SelectItem>
|
||
<SelectItem value="cash">Cash</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-muted-foreground">Account Type</Label>
|
||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as any)}>
|
||
<SelectTrigger className="w-40 mt-1"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All</SelectItem>
|
||
<SelectItem value="asset">Assets</SelectItem>
|
||
<SelectItem value="liability">Liabilities</SelectItem>
|
||
<SelectItem value="equity">Equity</SelectItem>
|
||
<SelectItem value="income">Income</SelectItem>
|
||
<SelectItem value="expense">Expenses</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center gap-2 pb-1">
|
||
<Switch id="zero" checked={showZero} onCheckedChange={setShowZero} />
|
||
<Label htmlFor="zero" className="text-sm">Show zero balances</Label>
|
||
</div>
|
||
<div className="ml-auto flex gap-2">
|
||
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{isLoading ? (
|
||
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
|
||
) : (
|
||
<ReportSheet
|
||
title="Trial Balance"
|
||
subtitle={`As of ${fmtDate(asOf)} · ${basis === "cash" ? "Cash" : "Accrual"} basis`}
|
||
companyName={companyName}
|
||
logoUrl={logoUrl}
|
||
>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
|
||
<th className="px-3 py-2 text-left font-semibold w-24">Code</th>
|
||
<th className="px-3 py-2 text-left font-semibold">Account</th>
|
||
<th className="px-3 py-2 text-right font-semibold w-40">Debit</th>
|
||
<th className="px-3 py-2 text-right font-semibold w-40">Credit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{TYPE_ORDER.map((t) => {
|
||
const rows = grouped[t];
|
||
if (!rows.length) return null;
|
||
const tDebit = rows.reduce((s, a) => s + splitDebitCredit(a).debit, 0);
|
||
const tCredit = rows.reduce((s, a) => s + splitDebitCredit(a).credit, 0);
|
||
return (
|
||
<>
|
||
<tr key={`h-${t}`} className="bg-muted/60 font-semibold">
|
||
<td className="px-3 py-2" colSpan={2}>{TYPE_LABEL[t]}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{fmt(tDebit)}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{fmt(tCredit)}</td>
|
||
</tr>
|
||
{rows.map((a) => {
|
||
const { debit, credit } = splitDebitCredit(a);
|
||
const zero = debit === 0 && credit === 0;
|
||
if (zero && !showZero) return null;
|
||
return (
|
||
<tr key={a.id} className={`border-b ${zero ? "text-muted-foreground italic" : ""}`}>
|
||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground">{a.code ?? ""}</td>
|
||
<td className="px-3 py-1.5 pl-8">{a.name}</td>
|
||
<td className="px-3 py-1.5 text-right tabular-nums">{fmt(debit)}</td>
|
||
<td className="px-3 py-1.5 text-right tabular-nums">{fmt(credit)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
})}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="border-t-2 border-b-2 border-primary font-bold text-base">
|
||
<td className="px-3 py-3" colSpan={2}>Total</td>
|
||
<td className="px-3 py-3 text-right tabular-nums">{fmt(totals.debit)}</td>
|
||
<td className="px-3 py-3 text-right tabular-nums">{fmt(totals.credit)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</ReportSheet>
|
||
)}
|
||
|
||
<Card>
|
||
<CardContent className="py-4 flex items-center justify-center">
|
||
{inBalance ? (
|
||
<Badge className="bg-green-600 hover:bg-green-600 text-white text-sm px-4 py-1.5 gap-2">
|
||
<CheckCircle2 className="h-4 w-4" /> In Balance
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="destructive" className="text-sm px-4 py-1.5 gap-2">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
Out of Balance by ${Math.abs(diff).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} — check for unposted journal entries or missing opening balances
|
||
</Badge>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|