Files
acmcc/src/pages/accounting/components/TrialBalanceReport.tsx
T
admin e302fb91f0 Accounting platform: remove Zoho, unify reports, board access, vendor sharing
- 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>
2026-06-02 18:29:31 -04:00

325 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 { 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>
);
}