mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
e302fb91f0
- 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>
912 lines
42 KiB
TypeScript
912 lines
42 KiB
TypeScript
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 (Jan–Mar)</SelectItem>
|
||
<SelectItem value="2">Q2 (Apr–Jun)</SelectItem>
|
||
<SelectItem value="3">Q3 (Jul–Sep)</SelectItem>
|
||
<SelectItem value="4">Q4 (Oct–Dec)</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&L–style 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>
|
||
);
|
||
}
|