mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2 } from "lucide-react";
|
||||
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";
|
||||
@@ -106,7 +107,7 @@ export default function AccountingOpeningBalancesPage() {
|
||||
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);
|
||||
.filter((r) => r.debit !== 0 || r.credit !== 0);
|
||||
|
||||
await accounting.from("opening_balances").delete().eq("company_id", cid);
|
||||
if (rowsPayload.length) {
|
||||
@@ -124,6 +125,78 @@ export default function AccountingOpeningBalancesPage() {
|
||||
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>;
|
||||
@@ -136,6 +209,19 @@ export default function AccountingOpeningBalancesPage() {
|
||||
<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">
|
||||
@@ -199,14 +285,14 @@ export default function AccountingOpeningBalancesPage() {
|
||||
<TableCell className="capitalize text-sm text-muted-foreground">{a.type}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isDebit ? (
|
||||
<Input type="number" step="0.01" min="0" inputMode="decimal" value={r.debit}
|
||||
<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" min="0" inputMode="decimal" value={r.credit}
|
||||
<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>}
|
||||
|
||||
Reference in New Issue
Block a user