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:
2026-06-02 18:29:31 -04:00
parent db20226d62
commit e302fb91f0
63 changed files with 2406 additions and 9514 deletions
+145 -99
View File
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { supabase } from "@/integrations/supabase/client";
import { useCompanyId } from "./lib/useCompanyId";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -29,12 +30,17 @@ import {
import { Lock } from "lucide-react";
import { TrialBalanceReport } from "./components/TrialBalanceReport";
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
import { ReserveFundReport } from "./components/ReserveFundReport";
import { ReportSheet } from "./components/ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
type ReportId =
| "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
| "trial-balance" | "general-ledger"
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation";
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation"
| "reserve-fund";
const APP_NAME = "Cozy Books";
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];
@@ -61,6 +67,9 @@ const GROUPS = [
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
]},
{ name: "Reserves", reports: [
{ id: "reserve-fund" as ReportId, name: "Reserve Fund Schedule" },
]},
{ name: "Audit", reports: [
{ id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
]},
@@ -188,8 +197,8 @@ function compareDates(mode: CompareMode, from: string, to: string, customFrom: s
return null;
}
export default function AccountingReportsPage() {
const { companyId, associationName } = useCompanyId();
export default function AccountingReportsPage({ association }: { association?: { id: string; name?: string } | null } = {}) {
const { companyId, associationName, associationId } = useCompanyId(association);
const cid = companyId ?? "";
const cur = "USD";
const [active, setActive] = useState<ReportId>("pnl");
@@ -218,7 +227,26 @@ export default function AccountingReportsPage() {
// Toggles
const [showCodes, setShowCodes] = useState(false);
const [showZero, setShowZero] = useState(false);
const [preview, setPreview] = useState(false);
const { data: companyMeta } = useQuery({
queryKey: ["company-fy", cid],
enabled: !!cid,
queryFn: async () => (await accounting.from("companies").select("fiscal_year_start").eq("id", cid).maybeSingle()).data,
});
const fiscalYearStart = (companyMeta as any)?.fiscal_year_start || "01-01";
// Association logo for branded reports (ACM fallback handled downstream).
const { data: assocMeta } = useQuery({
queryKey: ["assoc-logo", associationId],
enabled: !!associationId,
queryFn: async () => (await supabase.from("associations").select("logo_url").eq("id", associationId!).maybeSingle()).data,
});
const logoUrl = (assocMeta as any)?.logo_url || null;
// Preloaded logo dataURL for synchronous PDF header drawing.
const { data: brandedLogo } = useQuery({
queryKey: ["branded-logo", logoUrl],
queryFn: async () => await loadBrandedLogo(logoUrl),
});
const { data } = useReportData(cid, from, to);
const { data: prevData } = useReportData(
@@ -342,45 +370,35 @@ export default function AccountingReportsPage() {
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
const anyExportable = !!(structured || flat || exportFlat);
const doExportPDF = () => {
const doExportPDF = async () => {
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
const src = flat ?? exportFlat;
const logo = brandedLogo ?? (await loadBrandedLogo(logoUrl));
if (structured) {
const doc = renderReportPdf(
structured,
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero, logo },
);
doc.save(`${fileBase}.pdf`);
} else if (src) {
const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" });
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
doc.text(src.title, 14, 16);
doc.setFont("helvetica", "bold"); doc.setFontSize(9);
doc.text("Properties:", 14, 24);
const lw = doc.getTextWidth("Properties:");
doc.setFont("helvetica", "normal");
doc.text(` ${associationName ?? ""}`, 14 + lw, 24);
doc.setFont("helvetica", "bold"); doc.text("Period:", 14, 30);
const lw2 = doc.getTextWidth("Period:");
doc.setFont("helvetica", "normal"); doc.text(` ${rangeLabel}`, 14 + lw2, 30);
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: src.columns.length > 6 ? "landscape" : "portrait" });
const startY = drawBrandedHeader(doc, {
logo, title: src.title,
metaLines: [{ label: "Properties:", value: associationName ?? "" }, { label: "Period:", value: rangeLabel }],
});
const lastCol = src.columns.length - 1;
autoTable(doc, {
head: [src.columns],
body: src.rows.map(r => r.map(String)),
startY: 36,
startY,
margin: { left: 40, right: 40 },
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
alternateRowStyles: { fillColor: [247, 248, 250] },
columnStyles: { [lastCol]: { halign: "right" } },
didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; },
didDrawPage: () => {
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(110, 116, 122);
doc.text(`Created on ${new Date().toLocaleDateString("en-US")}`, 14, pageH - 8);
doc.text(`Page ${doc.getNumberOfPages()}`, pageW - 14, pageH - 8, { align: "right" });
},
});
drawBrandedFooter(doc);
doc.save(`${fileBase}.pdf`);
} else {
toast.error("No data to export for this report");
@@ -456,7 +474,6 @@ export default function AccountingReportsPage() {
<span className="text-xs text-muted-foreground self-center">Export available inside the report </span>
) : (
<>
{isFinancial && <Button variant="outline" onClick={() => setPreview(true)}><Eye className="mr-1 h-4 w-4" /> Preview</Button>}
<Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</>
@@ -525,20 +542,35 @@ export default function AccountingReportsPage() {
</Card>
</div>
{active === "budget-vs-actuals" && (
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} logoUrl={logoUrl} />
)}
{active === "trial-balance" && (
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)}
{active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} />
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)}
{active === "reserve-fund" && (
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
)}
{active === "reconciliation" && (
<ReconciliationReport d={data} currency={cur} />
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<ReconciliationReport d={data} currency={cur} />
</ReportSheet>
)}
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reconciliation" && (
<Card>
<CardContent>
{isFinancial && (
!data ? (
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading</div></CardContent></Card>
) : structured ? (
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
</ReportSheet>
) : (
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
)
)}
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && (
<ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
{!data ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : structured ? (
@@ -573,38 +605,10 @@ export default function AccountingReportsPage() {
) : (
<div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
)}
</CardContent>
</Card>
</ReportSheet>
)}
</div>
{/* Print Preview Modal */}
<Dialog open={preview} onOpenChange={setPreview}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Print preview · {activeMeta.name}</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center gap-5 border-y bg-muted/40 px-4 py-3 text-sm">
<Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Show account codes" />
<Toggle id="t-compare" checked={showCompare} onChange={(v) => setCompareMode(v ? "prior-year" : "none")} label="Show comparative period" />
<Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Show zero-balance accounts" />
</div>
<div className="overflow-auto flex-1 bg-muted/30 p-6">
<PreviewSheet
report={structured ?? (flat ? flatToStructured(flat, activeMeta.name) : null)}
companyName={associationName ?? "Company"}
rangeLabel={rangeLabel}
showCodes={showCodes}
showCompare={showCompare && isFinancial}
showZero={showZero}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPreview(false)}>Close</Button>
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> Download PDF</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1390,7 +1394,7 @@ function computeBvaActuals(actualsData: any, grouped: Record<string, any[]>, bud
return m;
}
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string }) {
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel, logoUrl }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string; logoUrl?: string | null }) {
const { data: budgets = [] } = useQuery({
queryKey: ["budgets-active", companyId],
enabled: !!companyId,
@@ -1484,6 +1488,38 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]);
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]);
// Comparison-window budget (pro-rated like budgetByAcct, over [cmpFrom, cmpTo]).
const cmpBudgetByAcct = useMemo(() => {
const m: Record<string, number> = {};
if (!cmpOn || !cmpFrom || !cmpTo) return m;
const pt = String(selectedBudget?.period_type ?? "annual");
const fy = Number(selectedBudget?.fiscal_year) || new Date(cmpFrom || cmpTo || Date.now()).getFullYear();
const fromT = new Date(cmpFrom).getTime();
const toT = new Date(cmpTo).getTime();
const DAY = 86400000;
const span = (idx: number): [number, number] => {
if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
};
for (const e of (entries as any[])) {
const [s, en] = span(Number(e.period_index) || 0);
const overlap = Math.max(0, Math.min(en, toT) - Math.max(s, fromT));
const full = en - s;
const weight = full > 0 ? Math.min(1, (overlap + DAY) / (full + DAY)) : 1;
if (weight <= 0) continue;
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount) * weight;
}
return m;
}, [entries, selectedBudget, cmpOn, cmpFrom, cmpTo]);
// Full-year budget per account (no pro-ration) for the Annual Budget column.
const annualBudgetByAcct = useMemo(() => {
const m: Record<string, number> = {};
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
return m;
}, [entries]);
const chartData = useMemo(() => {
const sumGroup = (type: "income" | "expense") => {
const accs = grouped[type] ?? [];
@@ -1530,22 +1566,51 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
URL.revokeObjectURL(url);
};
const exportPDF = () => {
const doc = new jsPDF({ unit: "pt", format: "letter" });
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
doc.text("Budget vs Actuals", 40, 50);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122);
doc.text(`${companyName} · ${actualsLabel} · Budget pro-rated to period`, 40, 66);
autoTable(doc, {
startY: 80,
head: [["Account", "Budget", "Actual", "Variance", "Variance %"]],
body: exportRows.map((r) => [r.label, money(r.budget, currency), money(r.actual, currency), money(r.variance, currency), r.pct]),
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
columnStyles: { 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" } },
didParseCell: ({ row, cell, section }) => { if (section === "body" && exportRows[row.index]?.group) cell.styles.fontStyle = "bold"; },
// Export using the shared branded Budget vs. Actuals generator (current period +
// optional comparison + Annual Budget columns), matching the main report style.
const exportPDF = async () => {
const nameById = new Map((accounts as any[]).map((a) => [a.id, a.name]));
const parentIds = new Set((accounts as any[]).filter((a) => a.parent_account_id).map((a) => a.parent_account_id));
const rows: any[] = [];
for (const type of ["income", "expense"] as const) {
for (const a of (grouped[type] ?? [])) {
if (parentIds.has(a.id)) continue; // parent accounts render as group headers
const budget = budgetByAcct[a.id] ?? 0;
const actual = actualByAcct[a.id] ?? 0;
const cmpA = cmpActualByAcct[a.id] ?? 0;
const cmpB = cmpBudgetByAcct[a.id] ?? 0;
const annual = annualBudgetByAcct[a.id] ?? 0;
rows.push({
id: a.id,
category: a.code ? `${a.code} ${a.name}` : a.name,
accountType: type,
parentId: a.parent_account_id ?? null,
parentCategory: a.parent_account_id ? (nameById.get(a.parent_account_id) ?? null) : null,
budget, annualBudget: annual, actual, variance: actual - budget,
pctOfBudget: budget ? (actual / budget) * 100 : 0,
comparisonActual: cmpA, comparisonBudget: cmpB, comparisonVariance: cmpA - cmpB,
comparisonPctOfBudget: cmpB ? (cmpA / cmpB) * 100 : 0, cmpDelta: 0,
});
}
}
const inc = rows.filter((r) => r.accountType === "income");
const exp = rows.filter((r) => r.accountType !== "income");
const sum = (rs: any[], k: string) => rs.reduce((s, r) => s + (Number(r[k]) || 0), 0);
await generateBudgetVsActualPdf({
association: { name: companyName, logo_url: logoUrl ?? null },
fiscalYear: Number(selectedBudget?.fiscal_year) || new Date().getFullYear(),
rangeLabel: actualsLabel,
comparisonLabel: cmpOn && cmpFrom && cmpTo ? "Comparison" : null,
comparisonRangeLabel: cmpOn && cmpFrom && cmpTo ? `${cmpFrom} to ${cmpTo}` : null,
rows,
totals: {
incomeBudget: sum(inc, "budget"), incomeActual: sum(inc, "actual"),
incomeCmp: sum(inc, "comparisonActual"), incomeCmpBudget: sum(inc, "comparisonBudget"),
expenseBudget: sum(exp, "budget"), expenseActual: sum(exp, "actual"),
expenseCmp: sum(exp, "comparisonActual"), expenseCmpBudget: sum(exp, "comparisonBudget"),
},
comparisonBudgetMonths: null,
});
doc.save(`${fileBase}.pdf`);
};
if (!budgets.length) {
@@ -1593,25 +1658,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Income vs Expenses</CardTitle></CardHeader>
<CardContent className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="name" />
<YAxis tickFormatter={(v) => money(v, currency)} width={90} />
<RTooltip formatter={(v: any) => money(Number(v), currency)} />
<Legend />
<Bar dataKey="Budget" fill="hsl(174 70% 45%)" />
<Bar dataKey="Actual" fill="hsl(214 80% 55%)" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
<ReportSheet title="Budget vs. Actuals" subtitle="Accrual basis" companyName={companyName} period={actualsLabel} logoUrl={logoUrl}>
<Table>
<TableHeader>
<TableRow>
@@ -1673,8 +1720,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
})}
</TableBody>
</Table>
</CardContent>
</Card>
</ReportSheet>
</div>
);
}