Files
acmcc/src/pages/budget/BudgetVsActualReport.tsx
T
admin e302fb91f0 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>
2026-06-02 18:29:31 -04:00

912 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ActualRow[]>([]);
const [budgets, setBudgets] = useState<BudgetRow[]>([]);
const [accountParents, setAccountParents] = useState<Record<string, string | null>>({});
const [accountNames, setAccountNames] = useState<Record<string, string>>({});
const [accountNumbers, setAccountNumbers] = useState<Record<string, string>>({});
const [coaIdByName, setCoaIdByName] = useState<Record<string, string>>({});
const [association, setAssociation] = useState<
{ id: string; name: string; logo_url: string | null; zoho_organization_id: string | null } | null
>(null);
const [loading, setLoading] = useState(false);
const [printing, setPrinting] = useState(false);
const [timeframe, setTimeframe] = useState<Timeframe>("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<string, string | null> = {};
const nameMap: Record<string, string> = {};
const numMap: Record<string, string> = {};
const idByName: Record<string, string> = {};
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<string>; names: Set<string>; display?: string };
type Aggregated = { byGl: Record<string, Bucket>; byName: Record<string, Bucket> };
const aggregate = (start: Date, end: Date): Aggregated => {
const byGl: Record<string, Bucket> = {};
const byName: Record<string, Bucket> = {};
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],
);
const curActuals = localCurActuals;
const cmpActuals = 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<string, string> = {};
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<string>();
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<string>();
const matchedNames = new Set<string>();
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<string>();
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.
const showUnbudgeted = !hideUnbudgeted;
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]);
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 (
<div className="space-y-6">
{/* Filters */}
<Card>
<CardContent className="pt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<Label className="text-xs uppercase text-muted-foreground">Timeframe</Label>
<Select value={timeframe} onValueChange={(v) => setTimeframe(v as Timeframe)}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ytd">Year to Date</SelectItem>
<SelectItem value="year">Full Year</SelectItem>
<SelectItem value="quarter">Quarter</SelectItem>
<SelectItem value="month">Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
</div>
{timeframe === "month" && (
<div>
<Label className="text-xs uppercase text-muted-foreground">Month</Label>
<Select value={month} onValueChange={setMonth}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }).map((_, i) => (
<SelectItem key={i} value={(i + 1).toString()}>
{new Date(fiscalYear, i, 1).toLocaleString("en-US", { month: "long" })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{timeframe === "quarter" && (
<div>
<Label className="text-xs uppercase text-muted-foreground">Quarter</Label>
<Select value={quarter} onValueChange={setQuarter}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Q1 (JanMar)</SelectItem>
<SelectItem value="2">Q2 (AprJun)</SelectItem>
<SelectItem value="3">Q3 (JulSep)</SelectItem>
<SelectItem value="4">Q4 (OctDec)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{timeframe === "custom" && (
<>
<div>
<Label className="text-xs uppercase text-muted-foreground">From</Label>
<Input type="date" className="mt-1" value={customStart} onChange={(e) => setCustomStart(e.target.value)} />
</div>
<div>
<Label className="text-xs uppercase text-muted-foreground">To</Label>
<Input type="date" className="mt-1" value={customEnd} onChange={(e) => setCustomEnd(e.target.value)} />
</div>
</>
)}
<div>
<Label className="text-xs uppercase text-muted-foreground">Compare To</Label>
<Select value={comparison} onValueChange={(v) => setComparison(v as any)}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No comparison</SelectItem>
<SelectItem value="prior_year">Prior Year YTD</SelectItem>
<SelectItem value="prior_year_full">Prior Year (Full Year)</SelectItem>
<SelectItem value="prior_period">Prior Period</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
</div>
{comparison === "custom" && (
<>
<div>
<Label className="text-xs uppercase text-muted-foreground">Compare Start</Label>
<Input type="date" className="mt-1" value={cmpCustomStart} onChange={(e) => setCmpCustomStart(e.target.value)} />
</div>
<div>
<Label className="text-xs uppercase text-muted-foreground">Compare End</Label>
<Input type="date" className="mt-1" value={cmpCustomEnd} onChange={(e) => setCmpCustomEnd(e.target.value)} />
</div>
</>
)}
<div className="flex items-end gap-2">
<Button variant="outline" className="gap-2 flex-1" onClick={downloadCsv}>
<Download className="h-4 w-4" /> CSV
</Button>
<Button className="gap-2 flex-1" onClick={downloadPdf} disabled={printing || !association}>
<Printer className="h-4 w-4" /> {printing ? "…" : "PDF"}
</Button>
</div>
<div className="flex items-center gap-2 md:col-span-4">
<Switch id="hide-unbudgeted" checked={hideUnbudgeted} onCheckedChange={setHideUnbudgeted} />
<Label htmlFor="hide-unbudgeted" className="text-sm cursor-pointer">
Hide unbudgeted line items
</Label>
</div>
</CardContent>
</Card>
{/* Range badges */}
<div className="flex flex-wrap gap-2">
<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>}
</div>
{/* KPI cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<TrendingUp className="h-4 w-4 text-emerald-600" /> Income Actual / Budget
</div>
<p className="text-2xl font-bold text-foreground">{fmt(totals.incomeActual)}</p>
<p className="text-xs text-muted-foreground mt-1">Budget: {fmt(totals.incomeBudget)}</p>
{comparison !== "none" && (
<p className="text-xs mt-1">
vs prior:{" "}
<span className={totals.incomeActual >= totals.incomeCmp ? "text-emerald-600" : "text-destructive"}>
{fmtPct(totals.incomeCmp !== 0 ? ((totals.incomeActual - totals.incomeCmp) / Math.abs(totals.incomeCmp)) * 100 : NaN)}
</span>
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<TrendingDown className="h-4 w-4 text-destructive" /> Expense Actual / Budget
</div>
<p className="text-2xl font-bold text-foreground">{fmt(totals.expenseActual)}</p>
<p className="text-xs text-muted-foreground mt-1">Budget: {fmt(totals.expenseBudget)}</p>
{comparison !== "none" && (
<p className="text-xs mt-1">
vs prior:{" "}
<span className={totals.expenseActual <= totals.expenseCmp ? "text-emerald-600" : "text-destructive"}>
{fmtPct(totals.expenseCmp !== 0 ? ((totals.expenseActual - totals.expenseCmp) / Math.abs(totals.expenseCmp)) * 100 : NaN)}
</span>
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<DollarSign className="h-4 w-4" /> Net Actual / Budget
</div>
<p className={`text-2xl font-bold ${netActual >= 0 ? "text-emerald-600" : "text-destructive"}`}>{fmt(netActual)}</p>
<p className="text-xs text-muted-foreground mt-1">Budget: {fmt(netBudget)}</p>
{comparison !== "none" && (
<p className="text-xs mt-1">
Prior: <span className={netCmp >= 0 ? "text-emerald-600" : "text-destructive"}>{fmt(netCmp)}</span>
</p>
)}
</CardContent>
</Card>
</div>
{/* Detail table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Budget vs Actual Detail</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : reportRows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
No budget lines or transactions found for this period.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Account Code</TableHead>
<TableHead className="text-right">Budget</TableHead>
<TableHead className="text-right">Annual Budget</TableHead>
<TableHead className="text-right">Actual</TableHead>
<TableHead className="text-right">Variance</TableHead>
<TableHead className="text-right">% Used</TableHead>
{comparison !== "none" && (
<>
<TableHead className="text-right">Comparison Actual</TableHead>
<TableHead className="text-right">Comparison Budget</TableHead>
<TableHead className="text-right">Comparison Variance</TableHead>
<TableHead className="text-right">Comparison %</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const extraCols = comparison !== "none" ? 4 : 0;
const totalCols = 7 + extraCols;
// Zoho P&Lstyle hierarchy: Section → Parent (with code) →
// child accounts → "Total for <Parent> <code>" → section total
const out: JSX.Element[] = [];
const numFmt = (n: number, bold = false) => (
<span className={bold ? "font-semibold" : ""}>{fmt(n)}</span>
);
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(
<TableRow key={r.id}>
<TableCell className={indent}>{r.category}</TableCell>
<TableCell className="text-muted-foreground">{r.accountCode || "—"}</TableCell>
<TableCell className="text-right">{fmt(r.budget)}</TableCell>
<TableCell className="text-right text-muted-foreground">{fmt(r.annualBudget)}</TableCell>
<TableCell className="text-right">{fmt(r.actual)}</TableCell>
<TableCell className={`text-right ${varianceGood ? "text-emerald-600" : "text-destructive"}`}>
{fmt(r.variance)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{r.budget > 0 ? `${r.pctOfBudget.toFixed(0)}%` : "—"}
</TableCell>
{comparison !== "none" && (
<>
<TableCell className="text-right">{fmt(r.comparisonActual)}</TableCell>
<TableCell className="text-right">{fmt(r.comparisonBudget)}</TableCell>
<TableCell className={`text-right ${isIncome ? (r.comparisonVariance >= 0 ? "text-emerald-600" : "text-destructive") : (r.comparisonVariance <= 0 ? "text-emerald-600" : "text-destructive")}`}>{fmt(r.comparisonVariance)}</TableCell>
<TableCell className="text-right text-muted-foreground">{r.comparisonBudget > 0 ? `${r.comparisonPctOfBudget.toFixed(0)}%` : "—"}</TableCell>
</>
)}
</TableRow>,
);
};
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(
<TableRow key={key} className={cls}>
<TableCell className={opts.indent || ""}>{label}</TableCell>
<TableCell className="text-muted-foreground">{code || ""}</TableCell>
<TableCell className="text-right">{numFmt(subBudget, true)}</TableCell>
<TableCell className="text-right text-muted-foreground">{numFmt(subAnnual, true)}</TableCell>
<TableCell className="text-right">{numFmt(subActual, true)}</TableCell>
<TableCell className={`text-right ${subVarianceGood ? "text-emerald-600" : "text-destructive"}`}>
{numFmt(subVar, true)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{subBudget > 0 ? `${((subActual / subBudget) * 100).toFixed(0)}%` : "—"}
</TableCell>
{comparison !== "none" && (
<>
<TableCell className="text-right">{numFmt(subCmp, true)}</TableCell>
<TableCell className="text-right">{numFmt(subCmpBudget, true)}</TableCell>
<TableCell className={`text-right ${subType === "income" ? (subCmpVar >= 0 ? "text-emerald-600" : "text-destructive") : (subCmpVar <= 0 ? "text-emerald-600" : "text-destructive")}`}>{numFmt(subCmpVar, true)}</TableCell>
<TableCell className="text-right text-muted-foreground">{subCmpBudget > 0 ? `${((subCmp / subCmpBudget) * 100).toFixed(0)}%` : "—"}</TableCell>
</>
)}
</TableRow>,
);
};
const renderSectionHeader = (label: string) =>
out.push(
<TableRow key={`sec-${label}`} className="bg-muted/40">
<TableCell colSpan={totalCols} className="font-bold text-sm uppercase tracking-wide">
{label}
</TableCell>
</TableRow>,
);
const renderSummaryRow = (label: string, budget: number, annualBudget: number, actual: number, cmp: number) => {
out.push(
<TableRow key={`sum-${label}`} className="bg-primary/5 border-t-2 border-primary/30">
<TableCell colSpan={2} className="font-bold text-center uppercase tracking-wide">
{label}
</TableCell>
<TableCell className="text-right font-bold">{fmt(budget)}</TableCell>
<TableCell className="text-right font-bold text-muted-foreground">{fmt(annualBudget)}</TableCell>
<TableCell className="text-right font-bold">{fmt(actual)}</TableCell>
<TableCell className="text-right font-bold">{fmt(actual - budget)}</TableCell>
<TableCell />
{comparison !== "none" && (
<>
<TableCell className="text-right font-bold">{fmt(cmp)}</TableCell>
<TableCell />
</>
)}
</TableRow>,
);
};
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<string, typeof reportRows>();
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(
<TableRow key={`grp-${type}-${key}`}>
<TableCell className="pl-4 font-semibold">{key}</TableCell>
<TableCell className="text-muted-foreground">{parentCode || "—"}</TableCell>
<TableCell colSpan={totalCols - 2} />
</TableRow>,
);
}
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;
})()}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}