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 = { 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 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(() => { const net = new Map(); // 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 = { 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 (
setAsOf(e.target.value)} className="w-44 mt-1" />
{isLoading ? ( Loading… ) : ( {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 ( <> {rows.map((a) => { const { debit, credit } = splitDebitCredit(a); const zero = debit === 0 && credit === 0; if (zero && !showZero) return null; return ( ); })} ); })}
Code Account Debit Credit
{TYPE_LABEL[t]} {fmt(tDebit)} {fmt(tCredit)}
{a.code ?? ""} {a.name} {fmt(debit)} {fmt(credit)}
Total {fmt(totals.debit)} {fmt(totals.credit)}
)} {inBalance ? ( In Balance ) : ( Out of Balance by ${Math.abs(diff).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} — check for unposted journal entries or missing opening balances )}
); }