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:
@@ -83,10 +83,6 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
||||
const [association, setAssociation] = useState<
|
||||
{ id: string; name: string; logo_url: string | null; zoho_organization_id: string | null } | null
|
||||
>(null);
|
||||
const [zohoCur, setZohoCur] = useState<Aggregated | null>(null);
|
||||
const [zohoCmp, setZohoCmp] = useState<Aggregated | null>(null);
|
||||
const [zohoLoading, setZohoLoading] = useState(false);
|
||||
const [zohoError, setZohoError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>("ytd");
|
||||
@@ -265,118 +261,8 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
||||
[ranges, actuals],
|
||||
);
|
||||
|
||||
// If the association is linked to a Zoho organization, fetch the P&L for the
|
||||
// current (and comparison) ranges and use those totals as the actuals source.
|
||||
const isZohoLinked = !!association?.zoho_organization_id;
|
||||
useEffect(() => {
|
||||
if (!isZohoLinked || !associationId) {
|
||||
setZohoCur(null);
|
||||
setZohoCmp(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Debounce so rapid filter changes don't fire overlapping (rate-limited) requests
|
||||
const debounce = setTimeout(() => { run(); }, 350);
|
||||
const toIso = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const flatten = (pl: any): { name: string; amount: number; type: "income" | "expense" }[] => {
|
||||
const out: { name: string; amount: number; type: "income" | "expense" }[] = [];
|
||||
const sections = pl?.profit_and_loss || pl?.profitandloss || pl?.sections || pl?.reports || pl?.rows || [];
|
||||
const walk = (nodes: any[], inheritedType: "income" | "expense" | null) => {
|
||||
for (const n of nodes || []) {
|
||||
const label = String(n?.section_name || n?.name || n?.account_name || n?.account_type || n?.total_label || "").toLowerCase();
|
||||
let t = inheritedType;
|
||||
if (/income|revenue|operating_income|other_income/.test(label)) t = "income";
|
||||
else if (/expense|cost|cogs|cost_of_goods/.test(label)) t = "expense";
|
||||
const accountName = n?.account_name || n?.name || n?.account || null;
|
||||
const hasChildren = zohoChildKeys.some((key) => asArray(n?.[key]).length > 0);
|
||||
const isTotal = !!n?.total_label || /^total\b/.test(String(accountName || "").trim().toLowerCase());
|
||||
const raw = parseMoney(n?.total ?? n?.amount ?? n?.amount_in_base_currency ?? n?.balance ?? n?.value);
|
||||
if (accountName && !hasChildren && !isTotal && raw !== 0) {
|
||||
out.push({ name: String(accountName), amount: Math.abs(raw), type: t || "expense" });
|
||||
}
|
||||
zohoChildKeys.forEach((key) => {
|
||||
const children = asArray(n?.[key]);
|
||||
if (children.length) walk(children, t);
|
||||
});
|
||||
}
|
||||
};
|
||||
walk(asArray(sections), null);
|
||||
return out;
|
||||
};
|
||||
const buildAgg = (items: { name: string; amount: number; type: "income" | "expense" }[]): Aggregated => {
|
||||
const byGl: Record<string, Bucket> = {};
|
||||
const byName: Record<string, Bucket> = {};
|
||||
const make = (): Bucket => ({ income: 0, expense: 0, glIds: new Set(), names: new Set() });
|
||||
items.forEach((it) => {
|
||||
const key = normalizeName(it.name);
|
||||
const nb = (byName[key] ||= make());
|
||||
if (!nb.display) nb.display = it.name;
|
||||
nb.names.add(key);
|
||||
const glId = coaIdByName[key];
|
||||
if (glId) nb.glIds.add(glId);
|
||||
if (it.type === "income") nb.income += it.amount; else nb.expense += it.amount;
|
||||
if (glId) {
|
||||
const gb = (byGl[glId] ||= make());
|
||||
gb.glIds.add(glId);
|
||||
gb.names.add(key);
|
||||
if (it.type === "income") gb.income += it.amount; else gb.expense += it.amount;
|
||||
}
|
||||
});
|
||||
return { byGl, byName };
|
||||
};
|
||||
const fetchOne = async (from: Date, to: Date) => {
|
||||
// Retry once on transient errors (rate limit / cold start)
|
||||
let lastErr: any = null;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
||||
body: {
|
||||
action: "get_profit_and_loss",
|
||||
params: { association_id: associationId, from_date: toIso(from), to_date: toIso(to) },
|
||||
},
|
||||
});
|
||||
if (!error) return buildAgg(flatten((data as any)?.data ?? data));
|
||||
lastErr = error;
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
}
|
||||
throw new Error(lastErr?.message || "Zoho fetch failed");
|
||||
};
|
||||
const hasAnyData = (agg: Aggregated | null) => {
|
||||
if (!agg) return false;
|
||||
const sumBuckets = (rec: Record<string, Bucket>) =>
|
||||
Object.values(rec).reduce((s, b) => s + Math.abs(b.income) + Math.abs(b.expense), 0);
|
||||
return sumBuckets(agg.byName) + sumBuckets(agg.byGl) > 0;
|
||||
};
|
||||
async function run() {
|
||||
setZohoLoading(true);
|
||||
setZohoError(null);
|
||||
try {
|
||||
const cur = await fetchOne(ranges.curStart, ranges.curEnd);
|
||||
if (cancelled) return;
|
||||
// If Zoho returned nothing parseable, fall back to local actuals rather
|
||||
// than rendering an empty (but truthy) aggregate that zeros every row.
|
||||
setZohoCur(hasAnyData(cur) ? cur : null);
|
||||
if (ranges.cmpStart && ranges.cmpEnd) {
|
||||
const cmp = await fetchOne(ranges.cmpStart, ranges.cmpEnd);
|
||||
if (cancelled) return;
|
||||
setZohoCmp(hasAnyData(cmp) ? cmp : null);
|
||||
} else {
|
||||
setZohoCmp(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[BudgetVsActualReport] Zoho P&L fetch failed, falling back to local:", e);
|
||||
// Do NOT clear previous successful results — that's what caused the
|
||||
// table to flicker / show wrong data when filters change quickly.
|
||||
if (!cancelled) setZohoError((e as Error)?.message || "Zoho fetch failed");
|
||||
} finally {
|
||||
if (!cancelled) setZohoLoading(false);
|
||||
}
|
||||
}
|
||||
return () => { cancelled = true; clearTimeout(debounce); };
|
||||
}, [isZohoLinked, associationId, ranges, coaIdByName]);
|
||||
|
||||
const curActuals = zohoCur || localCurActuals;
|
||||
const cmpActuals = zohoCmp || localCmpActuals;
|
||||
const curActuals = localCurActuals;
|
||||
const cmpActuals = localCmpActuals;
|
||||
|
||||
// Build report rows: one per leaf budget line, with matched actuals
|
||||
const reportRows = useMemo(() => {
|
||||
@@ -471,10 +357,7 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
||||
return false;
|
||||
};
|
||||
// Walk byName so each unbudgeted line shows under its human-readable name.
|
||||
// When Zoho is the actuals source, always surface unbudgeted lines so
|
||||
// expenses pulled from Zoho aren't silently hidden when they don't match
|
||||
// a local budget category.
|
||||
const showUnbudgeted = !hideUnbudgeted || !!zohoCur;
|
||||
const showUnbudgeted = !hideUnbudgeted;
|
||||
if (showUnbudgeted) {
|
||||
Object.entries(curActuals.byName).forEach(([key, v]) => {
|
||||
const isMatchedById = Array.from(v.glIds).some((id) => matchedGlIds.has(id));
|
||||
@@ -526,7 +409,7 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
||||
if (ac !== bc) return ac.localeCompare(bc, undefined, { numeric: true });
|
||||
return a.category.localeCompare(b.category);
|
||||
});
|
||||
}, [budgets, fiscalYear, curActuals, cmpActuals, budgetMonths, comparisonBudgetMonths, accountParents, accountNumbers, hideUnbudgeted, zohoCur]);
|
||||
}, [budgets, fiscalYear, curActuals, cmpActuals, budgetMonths, comparisonBudgetMonths, accountParents, accountNumbers, hideUnbudgeted]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const income = reportRows.filter((r) => r.accountType === "income");
|
||||
@@ -742,16 +625,6 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
||||
<Badge variant="secondary">Period: {rangeLabel}</Badge>
|
||||
{cmpRangeLabel && <Badge variant="outline">Compare: {cmpRangeLabel}</Badge>}
|
||||
{budgetMonths < 12 && <Badge variant="outline">Budget pro-rated to {budgetMonths} of 12 months</Badge>}
|
||||
{isZohoLinked && (
|
||||
<Badge variant="outline" className="border-emerald-500/40 text-emerald-700">
|
||||
{zohoLoading ? "Loading Zoho P&L…" : "Actuals: Zoho Books P&L"}
|
||||
</Badge>
|
||||
)}
|
||||
{zohoError && (
|
||||
<Badge variant="outline" className="border-amber-500/50 text-amber-700">
|
||||
Zoho refresh failed — showing previous data. {zohoError}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
|
||||
Reference in New Issue
Block a user