import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { format } from "date-fns"; import { CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload, FileDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { accounting } from "@/lib/accountingClient"; import { useCompanyId } from "./lib/useCompanyId"; import { parseCsv, pick } from "./lib/csv"; import { money } from "./lib/format"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; const TYPES = [ { value: "asset", label: "Assets", side: "debit" as const }, { value: "expense", label: "Expenses", side: "debit" as const }, { value: "liability", label: "Liabilities", side: "credit" as const }, { value: "equity", label: "Equity", side: "credit" as const }, { value: "income", label: "Income", side: "credit" as const }, ]; export default function AccountingOpeningBalancesPage() { const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); const cid = companyId ?? ""; const cur = "USD"; const qc = useQueryClient(); const { data: accounts = [] } = useQuery({ queryKey: ["accounts", cid], enabled: !!cid, queryFn: async () => (await accounting.from("accounts").select("id,name,code,type").eq("company_id", cid).order("code", { ascending: true })).data ?? [], }); const { data: setup } = useQuery({ queryKey: ["ob-setup", cid], enabled: !!cid, queryFn: async () => (await accounting.from("opening_balances_setup").select("*").eq("company_id", cid).maybeSingle()).data, }); const { data: balances = [] } = useQuery({ queryKey: ["ob", cid], enabled: !!cid, queryFn: async () => (await accounting.from("opening_balances").select("*").eq("company_id", cid)).data ?? [], }); const [rows, setRows] = useState>({}); const [asOfDate, setAsOfDate] = useState(new Date()); const [dateLocked, setDateLocked] = useState(false); useEffect(() => { if (setup) { setAsOfDate(new Date(setup.as_of_date)); setDateLocked(setup.confirmed); } }, [setup]); useEffect(() => { const map: Record = {}; for (const b of balances as any[]) { map[b.account_id] = { debit: Number(b.debit) ? String(b.debit) : "", credit: Number(b.credit) ? String(b.credit) : "", }; } setRows(map); }, [balances]); const update = (id: string, side: "debit" | "credit", v: string) => { setRows((r) => ({ ...r, [id]: { debit: side === "debit" ? v : "", credit: side === "credit" ? v : "" } })); }; const grouped = useMemo(() => { const out: Record = {}; for (const t of TYPES) out[t.value] = []; for (const a of accounts as any[]) (out[a.type] ??= []).push(a); return out; }, [accounts]); const totalDebit = useMemo(() => Object.values(rows).reduce((s, r) => s + (parseFloat(r.debit) || 0), 0), [rows]); const totalCredit = useMemo(() => Object.values(rows).reduce((s, r) => s + (parseFloat(r.credit) || 0), 0), [rows]); const diff = totalDebit - totalCredit; const balanced = Math.abs(diff) < 0.005; const save = async () => { if (!balanced) return toast.error("Debits and Credits must match"); const setupPayload = { company_id: cid, as_of_date: format(asOfDate, "yyyy-MM-dd"), confirmed: true, }; const { error: e1 } = await accounting.from("opening_balances_setup").upsert(setupPayload, { onConflict: "company_id" }); if (e1) return toast.error(e1.message); const rowsPayload = (accounts as any[]) .map((a: any) => { const r = rows[a.id]; const d = parseFloat(r?.debit || "0") || 0; const c = parseFloat(r?.credit || "0") || 0; return { company_id: cid, account_id: a.id, debit: d, credit: c }; }) .filter((r) => r.debit !== 0 || r.credit !== 0); await accounting.from("opening_balances").delete().eq("company_id", cid); if (rowsPayload.length) { const { error: e2 } = await accounting.from("opening_balances").insert(rowsPayload); if (e2) return toast.error(e2.message); } toast.success("Opening balances saved"); setDateLocked(true); qc.invalidateQueries({ queryKey: ["ob-setup", cid] }); qc.invalidateQueries({ queryKey: ["ob", cid] }); }; const resetAll = () => { setRows({}); toast.info("All balances cleared. Click Save to persist."); }; // ── CSV import: a trial balance per account (Account Code/Name, Debit, Credit) ── const fileRef = useRef(null); const importCsv = async (file: File) => { let parsed: Record[]; try { parsed = parseCsv(await file.text()); } catch { toast.error("Could not read the CSV file."); return; } if (!parsed.length) { toast.error("No rows found in the file."); return; } const byCode = new Map(); const byName = new Map(); for (const a of accounts as any[]) { if (a.code) byCode.set(String(a.code).trim().toLowerCase(), a); if (a.name) byName.set(String(a.name).trim().toLowerCase(), a); } // Signed parse: handles "$", commas, and parentheses/leading-minus as negative, // so a negative balance carries through to reporting (e.g. reserves). const numFrom = (s: string) => { const t = (s || "").trim(); const mag = Math.abs(parseFloat(t.replace(/[$,()]/g, "")) || 0); return /^\s*[-(]/.test(t) ? -mag : mag; }; const next: Record = {}; let matched = 0; let unmatched = 0; for (const r of parsed) { const code = pick(r["account code"], r["code"], r["account number"], r["account #"], r["acct"], r["number"]); const name = pick(r["account name"], r["account"], r["name"]); const acct = (code && byCode.get(code.trim().toLowerCase())) || (name && byName.get(name.trim().toLowerCase())) || null; if (!acct) { if (code || name) unmatched++; continue; } let debit = numFrom(pick(r["debit"], r["debits"])); let credit = numFrom(pick(r["credit"], r["credits"])); if (!debit && !credit) { // Single balance column → place on the account's natural side (sign preserved). const signed = numFrom(pick(r["balance"], r["amount"])); const debitNatural = acct.type === "asset" || acct.type === "expense"; if (debitNatural) debit = signed; else credit = signed; } if (debit || credit) { next[acct.id] = { debit: debit ? String(debit) : "", credit: credit ? String(credit) : "" }; matched++; } } if (!matched) { toast.error("No rows matched an account by code or name. Check your CSV headers (Account Code / Account Name, Debit, Credit)."); return; } setRows(next); toast.success( `Imported ${matched} account balance${matched === 1 ? "" : "s"}.${unmatched ? ` ${unmatched} row(s) didn't match an account.` : ""} Review and click Save.`, ); }; const downloadTemplate = () => { const header = "Account Code,Account Name,Debit,Credit"; const sample = (accounts as any[]).slice(0, 5).map((a) => `${a.code ?? ""},"${(a.name ?? "").replace(/"/g, '""')}",,`); const blob = new Blob([[header, ...sample].join("\n")], { type: "text/csv" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "trial-balance-template.csv"; link.click(); URL.revokeObjectURL(url); }; if (!associationId) return

Select an association.

; if (companyLoading) return
; if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; return (

Opening Balances

Set starting balances for each account from your previous accounting system.

{ const f = e.target.files?.[0]; if (f) importCsv(f); e.target.value = ""; }} />
As of date
d && setAsOfDate(d)} initialFocus className={cn("p-3 pointer-events-auto")} /> {dateLocked && ( )}

Enter the balances from your previous accounting system. Debits must equal Credits to save. Accounts with zero balances can remain blank.

Code Account name Type Debit Credit {TYPES.map((t) => { const rs = grouped[t.value] ?? []; if (!rs.length) return null; return ( {t.label} {rs.map((a: any) => { const r = rows[a.id] ?? { debit: "", credit: "" }; const isDebit = t.side === "debit"; return ( {a.code ?? "—"} {a.name} {a.type} {isDebit ? ( update(a.id, "debit", e.target.value)} placeholder="0.00" className="text-right ml-auto max-w-[160px]" /> ) : } {!isDebit ? ( update(a.id, "credit", e.target.value)} placeholder="0.00" className="text-right ml-auto max-w-[160px]" /> ) : } ); })} ); })} {(accounts as any[]).length === 0 && ( No accounts yet. Create some in Chart of Accounts first. )}
Totals
Debits{money(totalDebit, cur)}
Credits{money(totalCredit, cur)}
Difference {money(diff, cur)}
{balanced ? ( Balanced ) : ( Out of balance by {money(Math.abs(diff), cur)} )} {setup?.confirmed && Last saved {format(new Date(setup.updated_at), "PP")}}
Reset all opening balances? This clears every entered debit and credit on this page. Saved balances remain until you click Save. Cancel Reset
); }