import { useState, useEffect, useMemo, useCallback } from "react"; import { supabase } from "@/integrations/supabase/client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Switch } from "@/components/ui/switch"; import { Download, Loader2, TrendingUp, TrendingDown, DollarSign, Printer } from "lucide-react"; import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf"; type ActualRow = { association_id: string; period_month: string; // ISO date YYYY-MM-01 account_type: "income" | "expense" | string; gl_account_id: string | null; category_name: string | null; amount: number; }; type BudgetRow = { id: string; association_id: string; fiscal_year: number; category: string; budgeted_amount: number | null; account_type: string; is_parent: boolean; parent_id: string | null; gl_account_id: string | null; }; type Timeframe = "ytd" | "month" | "quarter" | "year" | "custom"; interface Props { associationId: string; fiscalYear: number; } const fmt = (n: number) => (n < 0 ? "-" : "") + "$" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtPct = (n: number) => isFinite(n) ? `${n >= 0 ? "+" : ""}${n.toFixed(1)}%` : "—"; const monthShort = (iso: string) => { const d = new Date(iso + "T12:00:00"); return d.toLocaleString("en-US", { month: "short", year: "2-digit" }); }; const normalizeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); const parseMoney = (value: unknown): number => { if (typeof value === "number") return value; if (value == null) return 0; const raw = String(value).trim(); if (!raw) return 0; const negative = raw.includes("(") && raw.includes(")"); const cleaned = raw.replace(/[,$\s]/g, "").replace(/[()]/g, ""); const parsed = Number(cleaned); return Number.isFinite(parsed) ? (negative ? -parsed : parsed) : 0; }; const zohoChildKeys = ["account_transactions", "accounts", "sections", "children", "child_accounts", "rows"]; const asArray = (value: unknown): any[] => { if (Array.isArray(value)) return value; if (value && typeof value === "object") return [value]; return []; }; export default function BudgetVsActualReport({ associationId, fiscalYear }: Props) { const [actuals, setActuals] = useState([]); const [budgets, setBudgets] = useState([]); const [accountParents, setAccountParents] = useState>({}); const [accountNames, setAccountNames] = useState>({}); const [accountNumbers, setAccountNumbers] = useState>({}); const [coaIdByName, setCoaIdByName] = useState>({}); const [association, setAssociation] = useState< { id: string; name: string; logo_url: string | null; zoho_organization_id: string | null } | null >(null); const [zohoCur, setZohoCur] = useState(null); const [zohoCmp, setZohoCmp] = useState(null); const [zohoLoading, setZohoLoading] = useState(false); const [zohoError, setZohoError] = useState(null); const [loading, setLoading] = useState(false); const [printing, setPrinting] = useState(false); const [timeframe, setTimeframe] = useState("ytd"); const [month, setMonth] = useState((new Date().getMonth() + 1).toString()); const [quarter, setQuarter] = useState("1"); const [customStart, setCustomStart] = useState(`${fiscalYear}-01-01`); const [customEnd, setCustomEnd] = useState(`${fiscalYear}-12-31`); const [comparison, setComparison] = useState< "none" | "prior_year" | "prior_year_full" | "prior_period" | "custom" >("prior_year"); const [cmpCustomStart, setCmpCustomStart] = useState(`${fiscalYear}-01-01`); const [cmpCustomEnd, setCmpCustomEnd] = useState(`${fiscalYear}-12-31`); const [hideUnbudgeted, setHideUnbudgeted] = useState(true); const fetchData = useCallback(async () => { if (!associationId) return; setLoading(true); // Fetch a wide window so prior-year/prior-period comparisons work const startWindow = `${fiscalYear - 1}-01-01`; const endWindow = `${fiscalYear + 1}-01-01`; const [actualsRes, budgetsRes, assocRes] = await Promise.all([ supabase .from("budget_actuals_monthly" as any) .select("*") .eq("association_id", associationId) .gte("period_month", startWindow) .lt("period_month", endWindow), supabase .from("budgets") .select("id, association_id, fiscal_year, category, budgeted_amount, account_type, is_parent, parent_id, gl_account_id") .eq("association_id", associationId) .in("fiscal_year", [fiscalYear, fiscalYear - 1]), supabase.from("associations").select("id, name, logo_url, zoho_organization_id").eq("id", associationId).maybeSingle(), ]); setActuals(((actualsRes.data as any) || []) as ActualRow[]); setBudgets(((budgetsRes.data as any) || []) as BudgetRow[]); setAssociation((assocRes.data as any) || null); // Fetch chart of accounts parent links so subaccount actuals roll up to parent budgets const coaRows: { id: string; parent_account_id: string | null; account_name: string | null }[] = []; let cFrom = 0; while (true) { const { data } = await supabase .from("chart_of_accounts") .select("id, parent_account_id, account_name, account_number") .range(cFrom, cFrom + 999); if (!data || data.length === 0) break; coaRows.push(...(data as any)); if (data.length < 1000) break; cFrom += 1000; } const parentMap: Record = {}; const nameMap: Record = {}; const numMap: Record = {}; const idByName: Record = {}; coaRows.forEach((r) => { parentMap[r.id] = r.parent_account_id; if (r.account_name) { nameMap[r.id] = r.account_name; idByName[normalizeName(r.account_name)] = r.id; } if ((r as any).account_number) numMap[r.id] = String((r as any).account_number); }); setAccountParents(parentMap); setAccountNames(nameMap); setAccountNumbers(numMap); setCoaIdByName(idByName); setLoading(false); }, [associationId, fiscalYear]); useEffect(() => { fetchData(); }, [fetchData]); // Build current and comparison date ranges const ranges = useMemo(() => { let curStart: Date, curEnd: Date; if (timeframe === "ytd") { curStart = new Date(fiscalYear, 0, 1); const today = new Date(); curEnd = today.getFullYear() === fiscalYear ? today : new Date(fiscalYear, 11, 31); } else if (timeframe === "year") { curStart = new Date(fiscalYear, 0, 1); curEnd = new Date(fiscalYear, 11, 31); } else if (timeframe === "month") { const m = parseInt(month) - 1; curStart = new Date(fiscalYear, m, 1); curEnd = new Date(fiscalYear, m + 1, 0); } else if (timeframe === "quarter") { const q = parseInt(quarter) - 1; curStart = new Date(fiscalYear, q * 3, 1); curEnd = new Date(fiscalYear, q * 3 + 3, 0); } else { curStart = new Date(customStart + "T12:00:00"); curEnd = new Date(customEnd + "T12:00:00"); } let cmpStart: Date | null = null, cmpEnd: Date | null = null; if (comparison === "prior_year") { cmpStart = new Date(curStart); cmpStart.setFullYear(cmpStart.getFullYear() - 1); cmpEnd = new Date(curEnd); cmpEnd.setFullYear(cmpEnd.getFullYear() - 1); } else if (comparison === "prior_year_full") { cmpStart = new Date(curStart.getFullYear() - 1, 0, 1); cmpEnd = new Date(curStart.getFullYear() - 1, 11, 31); } else if (comparison === "prior_period") { const days = Math.round((curEnd.getTime() - curStart.getTime()) / 86400000) + 1; cmpEnd = new Date(curStart); cmpEnd.setDate(cmpEnd.getDate() - 1); cmpStart = new Date(cmpEnd); cmpStart.setDate(cmpStart.getDate() - days + 1); } else if (comparison === "custom") { cmpStart = new Date(cmpCustomStart + "T12:00:00"); cmpEnd = new Date(cmpCustomEnd + "T12:00:00"); } return { curStart, curEnd, cmpStart, cmpEnd }; }, [timeframe, month, quarter, customStart, customEnd, fiscalYear, comparison, cmpCustomStart, cmpCustomEnd]); const inRange = (iso: string, start: Date, end: Date) => { const d = new Date(iso + "T12:00:00"); return d >= new Date(start.getFullYear(), start.getMonth(), 1) && d <= end; }; // Budgets are stored as MONTHLY amounts in `budgeted_amount`. // The period budget = monthly × number of months in the selected range. // The annual budget = monthly × 12. // Example: 01/01 – 04/30 covers 4 months → multiplier = 4. const countBudgetMonths = (start: Date, end: Date) => Math.max(0, (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()) + 1); const budgetMonths = useMemo(() => countBudgetMonths(ranges.curStart, ranges.curEnd), [ranges.curStart, ranges.curEnd]); const comparisonBudgetMonths = useMemo( () => (ranges.cmpStart && ranges.cmpEnd ? countBudgetMonths(ranges.cmpStart, ranges.cmpEnd) : null), [ranges.cmpStart, ranges.cmpEnd], ); // Aggregate actuals into two parallel indexes: one keyed by gl_account_id // and one keyed by lowercase category_name. This lets a budget line match // actuals whether it has a GL account linked or only a category label. type Bucket = { income: number; expense: number; glIds: Set; names: Set; display?: string }; type Aggregated = { byGl: Record; byName: Record }; const aggregate = (start: Date, end: Date): Aggregated => { const byGl: Record = {}; const byName: Record = {}; const make = (): Bucket => ({ income: 0, expense: 0, glIds: new Set(), names: new Set() }); actuals.forEach((a) => { if (!inRange(a.period_month, start, end)) return; const amt = Number(a.amount) || 0; const displayName = String(a.category_name || "Uncategorized"); const nameKey = normalizeName(displayName); // Always index by name so name-based budget matching still works for bills with a GL. const nb = (byName[nameKey] ||= make()); if (!nb.display) nb.display = displayName; if (a.gl_account_id) nb.glIds.add(a.gl_account_id); nb.names.add(nameKey); if (a.account_type === "income") nb.income += amt; else nb.expense += amt; // Also index by gl_account_id when present for direct ID lookups. if (a.gl_account_id) { const gb = (byGl[a.gl_account_id] ||= make()); gb.glIds.add(a.gl_account_id); if (a.category_name) gb.names.add(nameKey); if (a.account_type === "income") gb.income += amt; else gb.expense += amt; } }); return { byGl, byName }; }; const localCurActuals = useMemo(() => aggregate(ranges.curStart, ranges.curEnd), [ranges, actuals]); const localCmpActuals = useMemo( () => (ranges.cmpStart && ranges.cmpEnd ? aggregate(ranges.cmpStart, ranges.cmpEnd) : null), [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 = {}; const byName: Record = {}; 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) => 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; // Build report rows: one per leaf budget line, with matched actuals const reportRows = useMemo(() => { const fyAllBudgets = budgets.filter((b) => b.fiscal_year === fiscalYear); const parentNameById: Record = {}; fyAllBudgets.filter((b) => b.is_parent).forEach((p) => { parentNameById[p.id] = p.category; }); const fyBudgets = fyAllBudgets.filter((b) => !b.is_parent); const matchActual = (b: BudgetRow, source: Aggregated) => { // Prefer gl_account_id match when budget is linked to a GL account. // Also roll up any subaccount actuals whose ancestor chain includes this GL. if (b.gl_account_id) { let total = 0; let hit = false; Object.entries(source.byGl).forEach(([glId, v]) => { if (glId === b.gl_account_id) { hit = true; total += b.account_type === "income" ? v.income : v.expense; return; } // walk parent chain let p: string | null | undefined = accountParents[glId]; const seen = new Set(); while (p && !seen.has(p)) { seen.add(p); if (p === b.gl_account_id) { hit = true; total += b.account_type === "income" ? v.income : v.expense; break; } p = accountParents[p]; } }); if (hit) return total; } // Otherwise match by category name (covers legacy budgets and billable_expenses). const lc = normalizeName(b.category); const v = source.byName[lc]; if (!v) return 0; return b.account_type === "income" ? v.income : v.expense; }; const rows = fyBudgets.map((b) => { const cur = matchActual(b, curActuals); const cmp = cmpActuals ? matchActual(b, cmpActuals) : 0; const annualBudget = Number(b.budgeted_amount) || 0; const monthly = annualBudget / 12; const proratedBudget = monthly * budgetMonths; const variance = cur - proratedBudget; const pctOfBudget = proratedBudget > 0 ? (cur / proratedBudget) * 100 : 0; const comparisonBudget = comparisonBudgetMonths !== null ? monthly * comparisonBudgetMonths : 0; const comparisonVariance = cmp - comparisonBudget; const comparisonPctOfBudget = comparisonBudget > 0 ? (cmp / comparisonBudget) * 100 : 0; const cmpDelta = cmp !== 0 ? ((cur - cmp) / Math.abs(cmp)) * 100 : NaN; return { id: b.id, category: b.category, accountCode: b.gl_account_id ? (accountNumbers[b.gl_account_id] || "") : "", accountType: b.account_type, parentId: b.parent_id || null, parentCategory: b.parent_id ? (parentNameById[b.parent_id] || null) : null, parentCode: b.parent_id ? (fyAllBudgets.find((p) => p.id === b.parent_id)?.gl_account_id ? accountNumbers[fyAllBudgets.find((p) => p.id === b.parent_id)!.gl_account_id!] || "" : "") : "", budget: proratedBudget, annualBudget, actual: cur, variance, pctOfBudget, comparisonActual: cmp, comparisonBudget, comparisonVariance, comparisonPctOfBudget, cmpDelta, }; }); // Also include actuals that don't match any budget line. const matchedGlIds = new Set(); const matchedNames = new Set(); fyBudgets.forEach((b) => { if (b.gl_account_id) matchedGlIds.add(b.gl_account_id); matchedNames.add(normalizeName(b.category)); }); // Treat actuals as matched if any ancestor GL is a budgeted account. const isCoveredByParentBudget = (glId: string | undefined): boolean => { if (!glId) return false; let p: string | null | undefined = accountParents[glId]; const seen = new Set(); while (p && !seen.has(p)) { seen.add(p); if (matchedGlIds.has(p)) return true; p = accountParents[p]; } 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; if (showUnbudgeted) { Object.entries(curActuals.byName).forEach(([key, v]) => { const isMatchedById = Array.from(v.glIds).some((id) => matchedGlIds.has(id)); const isMatchedByName = matchedNames.has(key); const isMatchedByParent = Array.from(v.glIds).some((id) => isCoveredByParentBudget(id)); if (isMatchedById || isMatchedByName || isMatchedByParent) return; // Add unbudgeted lines for income and expense if amount > 0 ["income", "expense"].forEach((t) => { const amt = t === "income" ? v.income : v.expense; if (amt <= 0) return; const cmpBucket = cmpActuals?.byName[key]; const cmpAmt = cmpBucket ? (t === "income" ? cmpBucket.income : cmpBucket.expense) || 0 : 0; rows.push({ id: `unb-${t}-${key}`, category: (v.display || key) + " (unbudgeted)", accountCode: "", accountType: t, parentId: null, parentCategory: null, parentCode: "", budget: 0, annualBudget: 0, actual: amt, variance: -amt, pctOfBudget: 0, comparisonActual: cmpAmt, comparisonBudget: 0, comparisonVariance: cmpAmt, comparisonPctOfBudget: 0, cmpDelta: cmpAmt !== 0 ? ((amt - cmpAmt) / Math.abs(cmpAmt)) * 100 : NaN, }); }); }); } return rows.sort((a, b) => { if (a.accountType !== b.accountType) return a.accountType === "income" ? -1 : 1; // Sort by parent account code (then name), then child code (then name) const apc = a.parentCode || "~~~"; const bpc = b.parentCode || "~~~"; if (apc !== bpc) return apc.localeCompare(bpc, undefined, { numeric: true }); const ap = a.parentCategory || "~~~"; const bp = b.parentCategory || "~~~"; if (ap !== bp) return ap.localeCompare(bp); const ac = a.accountCode || "~~~"; const bc = b.accountCode || "~~~"; 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]); const totals = useMemo(() => { const income = reportRows.filter((r) => r.accountType === "income"); const expense = reportRows.filter((r) => r.accountType !== "income"); const sum = (rows: typeof reportRows, k: "budget" | "annualBudget" | "actual" | "comparisonActual" | "comparisonBudget") => rows.reduce((s, r) => s + (r[k] || 0), 0); return { incomeBudget: sum(income, "budget"), incomeAnnualBudget: sum(income, "annualBudget"), incomeActual: sum(income, "actual"), incomeCmp: sum(income, "comparisonActual"), incomeCmpBudget: sum(income, "comparisonBudget"), expenseBudget: sum(expense, "budget"), expenseAnnualBudget: sum(expense, "annualBudget"), expenseActual: sum(expense, "actual"), expenseCmp: sum(expense, "comparisonActual"), expenseCmpBudget: sum(expense, "comparisonBudget"), }; }, [reportRows]); const netBudget = totals.incomeBudget - totals.expenseBudget; const netActual = totals.incomeActual - totals.expenseActual; const netCmp = totals.incomeCmp - totals.expenseCmp; const netCmpBudget = totals.incomeCmpBudget - totals.expenseCmpBudget; const downloadCsv = () => { const headers = ["Type", "Category", "Budget", "Actual", "Variance", "% of Budget"]; if (comparison !== "none") headers.push("Comparison Actual", "Comparison Budget", "Comparison Variance", "Comparison % of Budget"); const lines = [headers.join(",")]; reportRows.forEach((r) => { const row = [ r.accountType, `"${r.category.replace(/"/g, '""')}"`, r.budget.toFixed(2), r.actual.toFixed(2), r.variance.toFixed(2), r.pctOfBudget.toFixed(1) + "%", ]; if (comparison !== "none") { row.push( r.comparisonActual.toFixed(2), r.comparisonBudget.toFixed(2), r.comparisonVariance.toFixed(2), r.comparisonBudget > 0 ? r.comparisonPctOfBudget.toFixed(1) + "%" : "—", ); } lines.push(row.join(",")); }); const blob = new Blob([lines.join("\n")], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `budget-vs-actual-FY${fiscalYear}.csv`; a.click(); URL.revokeObjectURL(url); }; const downloadPdf = async () => { if (!association) return; setPrinting(true); try { const comparisonLabel = comparison === "prior_year" ? "Prior Year (Same Period)" : comparison === "prior_year_full" ? "Prior Year (Full Year)" : comparison === "prior_period" ? "Prior Period" : comparison === "custom" ? "Custom Range" : null; await generateBudgetVsActualPdf({ association, fiscalYear, rangeLabel: `${ranges.curStart.toLocaleDateString()} – ${ranges.curEnd.toLocaleDateString()}`, comparisonLabel, comparisonRangeLabel: ranges.cmpStart && ranges.cmpEnd ? `${ranges.cmpStart.toLocaleDateString()} – ${ranges.cmpEnd.toLocaleDateString()}` : null, rows: reportRows, totals, comparisonBudgetMonths, }); } finally { setPrinting(false); } }; const rangeLabel = `${ranges.curStart.toLocaleDateString()} – ${ranges.curEnd.toLocaleDateString()}`; const cmpRangeLabel = ranges.cmpStart && ranges.cmpEnd ? `${ranges.cmpStart.toLocaleDateString()} – ${ranges.cmpEnd.toLocaleDateString()}` : null; return (
{/* Filters */}
{timeframe === "month" && (
)} {timeframe === "quarter" && (
)} {timeframe === "custom" && ( <>
setCustomStart(e.target.value)} />
setCustomEnd(e.target.value)} />
)}
{comparison === "custom" && ( <>
setCmpCustomStart(e.target.value)} />
setCmpCustomEnd(e.target.value)} />
)}
{/* Range badges */}
Period: {rangeLabel} {cmpRangeLabel && Compare: {cmpRangeLabel}} {budgetMonths < 12 && Budget pro-rated to {budgetMonths} of 12 months} {isZohoLinked && ( {zohoLoading ? "Loading Zoho P&L…" : "Actuals: Zoho Books P&L"} )} {zohoError && ( Zoho refresh failed — showing previous data. {zohoError} )}
{/* KPI cards */}
Income — Actual / Budget

{fmt(totals.incomeActual)}

Budget: {fmt(totals.incomeBudget)}

{comparison !== "none" && (

vs prior:{" "} = totals.incomeCmp ? "text-emerald-600" : "text-destructive"}> {fmtPct(totals.incomeCmp !== 0 ? ((totals.incomeActual - totals.incomeCmp) / Math.abs(totals.incomeCmp)) * 100 : NaN)}

)}
Expense — Actual / Budget

{fmt(totals.expenseActual)}

Budget: {fmt(totals.expenseBudget)}

{comparison !== "none" && (

vs prior:{" "} {fmtPct(totals.expenseCmp !== 0 ? ((totals.expenseActual - totals.expenseCmp) / Math.abs(totals.expenseCmp)) * 100 : NaN)}

)}
Net — Actual / Budget

= 0 ? "text-emerald-600" : "text-destructive"}`}>{fmt(netActual)}

Budget: {fmt(netBudget)}

{comparison !== "none" && (

Prior: = 0 ? "text-emerald-600" : "text-destructive"}>{fmt(netCmp)}

)}
{/* Detail table */} Budget vs Actual Detail {loading ? (
) : reportRows.length === 0 ? (
No budget lines or transactions found for this period.
) : ( Account Account Code Budget Annual Budget Actual Variance % Used {comparison !== "none" && ( <> Comparison Actual Comparison Budget Comparison Variance Comparison % )} {(() => { const extraCols = comparison !== "none" ? 4 : 0; const totalCols = 7 + extraCols; // Zoho P&L–style hierarchy: Section → Parent (with code) → // child accounts → "Total for " → section total const out: JSX.Element[] = []; const numFmt = (n: number, bold = false) => ( {fmt(n)} ); const renderLeaf = (r: typeof reportRows[number], indent = "pl-10") => { const isIncome = r.accountType === "income"; const varianceGood = isIncome ? r.variance >= 0 : r.variance <= 0; out.push( {r.category} {r.accountCode || "—"} {fmt(r.budget)} {fmt(r.annualBudget)} {fmt(r.actual)} {fmt(r.variance)} {r.budget > 0 ? `${r.pctOfBudget.toFixed(0)}%` : "—"} {comparison !== "none" && ( <> {fmt(r.comparisonActual)} {fmt(r.comparisonBudget)} = 0 ? "text-emerald-600" : "text-destructive") : (r.comparisonVariance <= 0 ? "text-emerald-600" : "text-destructive")}`}>{fmt(r.comparisonVariance)} {r.comparisonBudget > 0 ? `${r.comparisonPctOfBudget.toFixed(0)}%` : "—"} )} , ); }; const renderTotalRow = ( key: string, label: string, code: string, list: typeof reportRows, opts: { strong?: boolean; indent?: string; bg?: string } = {}, ) => { const subActual = list.reduce((s, r) => s + r.actual, 0); const subBudget = list.reduce((s, r) => s + r.budget, 0); const subAnnual = list.reduce((s, r) => s + (r.annualBudget || 0), 0); const subVar = subActual - subBudget; const subType = list[0]?.accountType; const subVarianceGood = subType === "income" ? subVar >= 0 : subVar <= 0; const subCmp = list.reduce((s, r) => s + r.comparisonActual, 0); const subCmpBudget = list.reduce((s, r) => s + r.comparisonBudget, 0); const subCmpVar = subCmp - subCmpBudget; const cls = `${opts.bg || "bg-muted/10"} ${opts.strong ? "font-bold" : "font-semibold"}`; out.push( {label} {code || ""} {numFmt(subBudget, true)} {numFmt(subAnnual, true)} {numFmt(subActual, true)} {numFmt(subVar, true)} {subBudget > 0 ? `${((subActual / subBudget) * 100).toFixed(0)}%` : "—"} {comparison !== "none" && ( <> {numFmt(subCmp, true)} {numFmt(subCmpBudget, true)} = 0 ? "text-emerald-600" : "text-destructive") : (subCmpVar <= 0 ? "text-emerald-600" : "text-destructive")}`}>{numFmt(subCmpVar, true)} {subCmpBudget > 0 ? `${((subCmp / subCmpBudget) * 100).toFixed(0)}%` : "—"} )} , ); }; const renderSectionHeader = (label: string) => out.push( {label} , ); const renderSummaryRow = (label: string, budget: number, annualBudget: number, actual: number, cmp: number) => { out.push( {label} {fmt(budget)} {fmt(annualBudget)} {fmt(actual)} {fmt(actual - budget)} {comparison !== "none" && ( <> {fmt(cmp)} )} , ); }; const renderTypeSection = (type: "income" | "expense", sectionLabel: string) => { const sectionRows = reportRows.filter((r) => type === "income" ? r.accountType === "income" : r.accountType !== "income", ); if (sectionRows.length === 0) { renderSectionHeader(sectionLabel); renderTotalRow( `total-${type}`, `Total for ${sectionLabel}`, "", [], { strong: true, bg: "bg-muted/20" }, ); return { budget: 0, annualBudget: 0, actual: 0, cmp: 0 }; } renderSectionHeader(sectionLabel); const groups = new Map(); for (const r of sectionRows) { const key = r.parentCategory || "__orphan__"; const list = (groups.get(key) || []) as typeof reportRows; list.push(r); groups.set(key, list); } const keys = Array.from(groups.keys()).sort((a, b) => { if (a === "__orphan__") return 1; if (b === "__orphan__") return -1; const acode = groups.get(a)![0].parentCode || "~~~"; const bcode = groups.get(b)![0].parentCode || "~~~"; if (acode !== bcode) return acode.localeCompare(bcode, undefined, { numeric: true }); return a.localeCompare(b); }); for (const key of keys) { const list = groups.get(key)!; const isOrphan = key === "__orphan__"; const parentCode = isOrphan ? "" : list[0].parentCode || ""; if (!isOrphan) { // Parent row (shows the parent account label + code, no values — they roll up below) out.push( {key} {parentCode || "—"} , ); } for (const r of list) renderLeaf(r, isOrphan ? "pl-4" : "pl-10"); if (!isOrphan) { renderTotalRow( `sub-${type}-${key}`, `Total for ${key}`, parentCode, list, { bg: "bg-muted/20", indent: "pl-4" }, ); } } const budget = sectionRows.reduce((s, r) => s + r.budget, 0); const annualBudget = sectionRows.reduce((s, r) => s + (r.annualBudget || 0), 0); const actual = sectionRows.reduce((s, r) => s + r.actual, 0); const cmp = sectionRows.reduce((s, r) => s + r.comparisonActual, 0); renderTotalRow( `total-${type}`, `Total for ${sectionLabel}`, "", sectionRows, { strong: true, bg: "bg-muted/30" }, ); return { budget, annualBudget, actual, cmp }; }; const income = renderTypeSection("income", "Operating Income"); renderSummaryRow("Gross Profit", income.budget, income.annualBudget, income.actual, income.cmp); const expense = renderTypeSection("expense", "Operating Expense"); renderSummaryRow( "Net Profit / Loss", income.budget - expense.budget, income.annualBudget - expense.annualBudget, income.actual - expense.actual, income.cmp - expense.cmp, ); return out; })()}
)}
); }