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>
360 lines
17 KiB
TypeScript
360 lines
17 KiB
TypeScript
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<Record<string, { debit: string; credit: string }>>({});
|
|
const [asOfDate, setAsOfDate] = useState<Date>(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<string, { debit: string; credit: string }> = {};
|
|
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<string, any[]> = {};
|
|
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<HTMLInputElement>(null);
|
|
|
|
const importCsv = async (file: File) => {
|
|
let parsed: Record<string, string>[];
|
|
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<string, any>();
|
|
const byName = new Map<string, any>();
|
|
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<string, { debit: string; credit: string }> = {};
|
|
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 <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 flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Opening Balances</h1>
|
|
<p className="text-sm text-muted-foreground">Set starting balances for each account from your previous accounting system.</p>
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
className="hidden"
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) importCsv(f); e.target.value = ""; }}
|
|
/>
|
|
<Button variant="outline" onClick={downloadTemplate} title="Download a CSV template">
|
|
<FileDown className="mr-1 h-4 w-4" /> Template
|
|
</Button>
|
|
<Button variant="outline" onClick={() => fileRef.current?.click()} title="Import a trial balance CSV">
|
|
<Upload className="mr-1 h-4 w-4" /> Import CSV
|
|
</Button>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs text-muted-foreground">As of date</span>
|
|
<div className="flex items-center gap-1">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" disabled={dateLocked} className={cn("w-[200px] justify-start text-left font-normal", dateLocked && "opacity-100")}>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{format(asOfDate, "PPP")}
|
|
{dateLocked && <Lock className="ml-auto h-3.5 w-3.5 text-muted-foreground" />}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="end">
|
|
<Calendar mode="single" selected={asOfDate} onSelect={(d) => d && setAsOfDate(d)} initialFocus className={cn("p-3 pointer-events-auto")} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
{dateLocked && (
|
|
<Button variant="ghost" size="icon" onClick={() => setDateLocked(false)} title="Edit date">
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-3 rounded-md border border-primary/20 bg-primary/5 px-4 py-3 text-sm">
|
|
<Info className="h-4 w-4 mt-0.5 text-primary shrink-0" />
|
|
<p>Enter the balances from your previous accounting system. <strong>Debits must equal Credits to save.</strong> Accounts with zero balances can remain blank.</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-28">Code</TableHead>
|
|
<TableHead>Account name</TableHead>
|
|
<TableHead className="w-32">Type</TableHead>
|
|
<TableHead className="text-right w-44">Debit</TableHead>
|
|
<TableHead className="text-right w-44">Credit</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{TYPES.map((t) => {
|
|
const rs = grouped[t.value] ?? [];
|
|
if (!rs.length) return null;
|
|
return (
|
|
<Fragment key={t.value}>
|
|
<TableRow key={`g-${t.value}`} className="bg-muted/40 hover:bg-muted/40">
|
|
<TableCell colSpan={5} className="font-semibold text-xs uppercase tracking-wide text-muted-foreground py-2">
|
|
{t.label}
|
|
</TableCell>
|
|
</TableRow>
|
|
{rs.map((a: any) => {
|
|
const r = rows[a.id] ?? { debit: "", credit: "" };
|
|
const isDebit = t.side === "debit";
|
|
return (
|
|
<TableRow key={a.id}>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">{a.code ?? "—"}</TableCell>
|
|
<TableCell className="font-medium">{a.name}</TableCell>
|
|
<TableCell className="capitalize text-sm text-muted-foreground">{a.type}</TableCell>
|
|
<TableCell className="text-right">
|
|
{isDebit ? (
|
|
<Input type="number" step="0.01" inputMode="decimal" value={r.debit}
|
|
onChange={(e) => update(a.id, "debit", e.target.value)}
|
|
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
|
) : <span className="text-muted-foreground">—</span>}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{!isDebit ? (
|
|
<Input type="number" step="0.01" inputMode="decimal" value={r.credit}
|
|
onChange={(e) => update(a.id, "credit", e.target.value)}
|
|
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
|
) : <span className="text-muted-foreground">—</span>}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
{(accounts as any[]).length === 0 && (
|
|
<TableRow><TableCell colSpan={5} className="text-center text-sm text-muted-foreground py-8">No accounts yet. Create some in Chart of Accounts first.</TableCell></TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
<div className="border-t bg-muted/30 px-4 py-3">
|
|
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-6 text-sm">
|
|
<div className="font-semibold">Totals</div>
|
|
<div className="text-right"><span className="text-muted-foreground mr-2">Debits</span><span className="font-semibold tabular-nums">{money(totalDebit, cur)}</span></div>
|
|
<div className="text-right"><span className="text-muted-foreground mr-2">Credits</span><span className="font-semibold tabular-nums">{money(totalCredit, cur)}</span></div>
|
|
<div className="text-right">
|
|
<span className="text-muted-foreground mr-2">Difference</span>
|
|
<span className={cn("font-semibold tabular-nums", balanced ? "text-emerald-600" : "text-red-600")}>
|
|
{money(diff, cur)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
{balanced ? (
|
|
<Badge className="bg-emerald-100 text-emerald-700 border-0">Balanced</Badge>
|
|
) : (
|
|
<Badge className="bg-red-100 text-red-700 border-0">Out of balance by {money(Math.abs(diff), cur)}</Badge>
|
|
)}
|
|
{setup?.confirmed && <Badge variant="outline">Last saved {format(new Date(setup.updated_at), "PP")}</Badge>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="outline"><RotateCcw className="mr-2 h-4 w-4" /> Reset All</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Reset all opening balances?</AlertDialogTitle>
|
|
<AlertDialogDescription>This clears every entered debit and credit on this page. Saved balances remain until you click Save.</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={resetAll}>Reset</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
<Button onClick={save} disabled={!balanced || (accounts as any[]).length === 0}>
|
|
<Save className="mr-2 h-4 w-4" /> Save Opening Balances
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|