Budget Workbook: replace Budget Management with a YTD-actuals workbook

/dashboard/budget-management now renders a Budget Workbook that pulls YTD
actuals (from the accounting GL, through a chosen month), derives a monthly
average (YTD/N), takes a per-line inflation %, and projects an annual budget
(avg x 12 x (1+infl)). Footer rolls up annual budget / 12 / # units = per-unit
monthly assessment. Income + expense sections; all imported fields editable.

- Standalone saved worksheet (accounting.budget_workbooks/_lines, RLS like accounts)
- "Push to Budget" writes projected/12 into accounting.budgets + budget_entries
- Uses accounting.accounts (synced with the Accounting dashboard COA) and paginates
  the GL fetch (1000-row cap). Nav relabeled Budget Management -> Budget Workbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 16:29:25 -04:00
parent ea2f2a089a
commit 215ecb3153
5 changed files with 406 additions and 4 deletions
+2 -2
View File
@@ -102,7 +102,7 @@ import HomeownerRequestsPage from "./pages/HomeownerRequestsPage";
import LegalMattersPage from "./pages/LegalMattersPage";
import ParkingPage from "./pages/ParkingPage";
import PaymentPlansPage from "./pages/PaymentPlansPage";
import BudgetManagementPage from "./pages/BudgetManagementPage";
import BudgetWorkbookPage from "./pages/BudgetWorkbookPage";
import BankAccountsPage from "./pages/BankAccountsPage";
import BankRegisterPage from "./pages/BankRegisterPage";
@@ -360,7 +360,7 @@ const App = () => (
<Route path="violations" element={<ViolationsPage />} />
<Route path="announcements" element={<AnnouncementsPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="budget-management" element={<BudgetManagementPage />} />
<Route path="budget-management" element={<BudgetWorkbookPage />} />
<Route path="budget-management/:id" element={<AccountingBudgetDetailPage basePath="/dashboard/budget-management" />} />
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
<Route path="bank-accounts-list" element={<BankAccountsPage />} />
+1 -1
View File
@@ -132,7 +132,7 @@ const sections: SectionDef[] = [
{ title: "Financial Overview", url: "/dashboard/financial-overview", icon: PieChart },
{ title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
{ title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock },
{ title: "Budget Management", url: "/dashboard/budget-management", icon: BarChart3 },
{ title: "Budget Workbook", url: "/dashboard/budget-management", icon: BarChart3 },
],
},
{
+1 -1
View File
@@ -160,7 +160,7 @@ const megaMenuSections: MegaMenuSection[] = [
{ title: "Financial Overview", url: "/dashboard/financial-overview", icon: PieChart },
{ title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
{ title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock },
{ title: "Budget Management", url: "/dashboard/budget-management", icon: BarChart3 },
{ title: "Budget Workbook", url: "/dashboard/budget-management", icon: BarChart3 },
],
},
{
+361
View File
@@ -0,0 +1,361 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, RefreshCw, Save, Upload, Download, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useAssociation } from "@/contexts/AssociationContext";
import { supabase } from "@/integrations/supabase/client";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "@/pages/accounting/lib/useCompanyId";
import { money } from "@/pages/accounting/lib/format";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// PostgREST caps responses at 1000 rows; page through all GL lines.
async function fetchAllGLLines(cid: string, from: string, to: string): Promise<any[]> {
const PAGE = 1000;
const out: any[] = [];
for (let offset = 0; ; offset += PAGE) {
const { data, error } = await accounting
.from("journal_entry_lines")
.select("id,debit,credit,account_id,journal_entries!inner(company_id,date)")
.eq("journal_entries.company_id", cid)
.gte("journal_entries.date", from)
.lte("journal_entries.date", to)
.order("id", { ascending: true })
.range(offset, offset + PAGE - 1);
if (error) throw error;
const rows = (data ?? []) as any[];
out.push(...rows);
if (rows.length < PAGE) break;
}
return out;
}
const monthEnd = (y: number, m: number) => `${y}-${String(m).padStart(2, "0")}-${String(new Date(y, m, 0).getDate()).padStart(2, "0")}`;
const n2 = (s: string) => (s.trim() === "" ? null : Number(s));
export default function BudgetWorkbookPage() {
const qc = useQueryClient();
const { associations, selectedAssociation, setSelectedAssociation, loadingAssociations } =
useAssociation() as any;
const { companyId, associationId, loading: companyLoading, error: companyError } = useCompanyId();
const cid = companyId ?? "";
const now = new Date();
const [fy, setFy] = useState(now.getFullYear());
const [through, setThrough] = useState(now.getMonth() + 1); // 1-12
// editable override maps (string; "" = no override / use computed)
const [ytdOv, setYtdOv] = useState<Record<string, string>>({});
const [infl, setInfl] = useState<Record<string, string>>({});
const [projOv, setProjOv] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [pushing, setPushing] = useState(false);
const sortedAssoc = useMemo(
() => [...(associations ?? [])].sort((a: any, b: any) => a.name.localeCompare(b.name)),
[associations],
);
const from = `${fy}-01-01`;
const to = monthEnd(fy, through);
const { data: accounts = [] } = useQuery({
queryKey: ["wb-accounts", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("accounts").select("id,name,code,type").eq("company_id", cid)
.in("type", ["income", "expense"]).order("code", { nullsFirst: false })).data ?? [],
});
const { data: glLines = [], isFetching: glFetching } = useQuery({
queryKey: ["wb-gl", cid, from, to],
enabled: !!cid,
queryFn: () => fetchAllGLLines(cid, from, to),
});
const { data: unitCount = 0 } = useQuery({
queryKey: ["wb-units", associationId],
enabled: !!associationId,
queryFn: async () => {
const { count } = await supabase.from("units").select("*", { count: "exact", head: true })
.eq("association_id", associationId!);
return count ?? 0;
},
});
// Persisted workbook state
const { data: workbook } = useQuery({
queryKey: ["wb-state", cid, fy],
enabled: !!cid,
queryFn: async () => {
const { data: head } = await accounting.from("budget_workbooks")
.select("*").eq("company_id", cid).eq("fiscal_year", fy).maybeSingle();
if (!head) return { head: null, lines: [] as any[] };
const { data: lines } = await accounting.from("budget_workbook_lines")
.select("*").eq("workbook_id", (head as any).id);
return { head, lines: lines ?? [] };
},
});
// Hydrate editable maps + through-month from persisted state
useEffect(() => {
if (!workbook) return;
const y: Record<string, string> = {}, i: Record<string, string> = {}, p: Record<string, string> = {};
for (const l of (workbook.lines as any[]) ?? []) {
if (l.ytd_override != null) y[l.account_id] = String(l.ytd_override);
if (l.inflation_pct != null) i[l.account_id] = String(l.inflation_pct);
if (l.projected_override != null) p[l.account_id] = String(l.projected_override);
}
setYtdOv(y); setInfl(i); setProjOv(p);
if ((workbook.head as any)?.through_month) setThrough((workbook.head as any).through_month);
}, [workbook]);
// Computed YTD actual per account from the GL
const ytdComputed = useMemo(() => {
const typeById = new Map<string, string>((accounts as any[]).map((a) => [a.id, a.type]));
const out: Record<string, number> = {};
for (const l of glLines as any[]) {
const t = typeById.get(l.account_id);
if (!t) continue;
const d = Number(l.debit) || 0, c = Number(l.credit) || 0;
out[l.account_id] = (out[l.account_id] ?? 0) + (t === "expense" ? d - c : c - d);
}
return out;
}, [glLines, accounts]);
const N = Math.max(1, through);
const ytdVal = (id: string) => (ytdOv[id]?.trim() ? Number(ytdOv[id]) : (ytdComputed[id] ?? 0));
const avgVal = (id: string) => ytdVal(id) / N;
const inflFrac = (id: string) => (Number(infl[id]) || 0) / 100;
const compProj = (id: string) => avgVal(id) * 12 * (1 + inflFrac(id));
const projVal = (id: string) => (projOv[id]?.trim() ? Number(projOv[id]) : compProj(id));
const sections = useMemo(() => {
const exp = (accounts as any[]).filter((a) => a.type === "expense");
const inc = (accounts as any[]).filter((a) => a.type === "income");
return { exp, inc };
}, [accounts]);
const sum = (rows: any[]) => rows.reduce((s, a) => s + projVal(a.id), 0);
const expenseAnnual = sum(sections.exp);
const incomeAnnual = sum(sections.inc);
const monthly = expenseAnnual / 12;
const perUnit = unitCount > 0 ? monthly / unitCount : 0;
const resetRow = (id: string) => {
setYtdOv((m) => ({ ...m, [id]: "" }));
setProjOv((m) => ({ ...m, [id]: "" }));
setInfl((m) => ({ ...m, [id]: "" }));
};
const saveWorkbook = async () => {
if (!cid) return;
setSaving(true);
try {
const { data: head, error: hErr } = await accounting.from("budget_workbooks")
.upsert({ company_id: cid, fiscal_year: fy, through_month: through, updated_at: new Date().toISOString() },
{ onConflict: "company_id,fiscal_year" })
.select("id").single();
if (hErr || !head) throw new Error(hErr?.message || "save failed");
await accounting.from("budget_workbook_lines").delete().eq("workbook_id", (head as any).id);
const rows = (accounts as any[])
.map((a) => ({
workbook_id: (head as any).id, account_id: a.id,
ytd_override: n2(ytdOv[a.id] ?? ""), inflation_pct: n2(infl[a.id] ?? ""),
projected_override: n2(projOv[a.id] ?? ""),
}))
.filter((r) => r.ytd_override != null || r.inflation_pct != null || r.projected_override != null);
if (rows.length) {
const { error } = await accounting.from("budget_workbook_lines").insert(rows);
if (error) throw new Error(error.message);
}
toast.success("Workbook saved");
qc.invalidateQueries({ queryKey: ["wb-state", cid, fy] });
} catch (e: any) {
toast.error(e.message || "Failed to save");
} finally {
setSaving(false);
}
};
const pushToBudget = async () => {
if (!cid) return;
if (!confirm(`Push projected annual amounts into the FY${fy} budget? This replaces that budget's monthly figures.`)) return;
setPushing(true);
try {
let { data: budget } = await accounting.from("budgets")
.select("id").eq("company_id", cid).eq("fiscal_year", fy).eq("period_type", "monthly").maybeSingle();
if (!budget) {
const { data: created, error } = await accounting.from("budgets")
.insert({ company_id: cid, name: `Budget Workbook FY${fy}`, fiscal_year: fy, period_type: "monthly", status: "draft" })
.select("id").single();
if (error || !created) throw new Error(error?.message || "Could not create budget");
budget = created;
}
await accounting.from("budget_entries").delete().eq("budget_id", (budget as any).id);
const rows: any[] = [];
for (const a of accounts as any[]) {
const per = projVal(a.id) / 12;
if (Math.abs(per) < 0.005) continue;
for (let i = 0; i < 12; i++) rows.push({ budget_id: (budget as any).id, account_id: a.id, period_index: i, amount: Number(per.toFixed(2)) });
}
if (rows.length) {
const { error } = await accounting.from("budget_entries").insert(rows);
if (error) throw new Error(error.message);
}
toast.success(`Pushed to FY${fy} budget`);
} catch (e: any) {
toast.error(e.message || "Failed to push");
} finally {
setPushing(false);
}
};
const exportCsv = () => {
const lines = [["Type", "Code", "Account", `YTD through ${MONTHS[through - 1]}`, "Monthly Avg", "Inflation %", "Projected Annual"].join(",")];
for (const a of [...sections.inc, ...sections.exp]) {
lines.push([a.type, a.code ?? "", `"${(a.name ?? "").replace(/"/g, '""')}"`,
ytdVal(a.id).toFixed(2), avgVal(a.id).toFixed(2), infl[a.id] ?? "", projVal(a.id).toFixed(2)].join(","));
}
lines.push(["", "", "EXPENSE ANNUAL", "", "", "", expenseAnnual.toFixed(2)].join(","));
lines.push(["", "", "PER UNIT / MONTH", "", "", "", perUnit.toFixed(2)].join(","));
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a"); link.href = url; link.download = `budget-workbook-FY${fy}.csv`; link.click();
URL.revokeObjectURL(url);
};
const renderSection = (title: string, rows: any[]) => (
<Card>
<CardHeader className="pb-2"><CardTitle className="text-base">{title}</CardTitle></CardHeader>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-y text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">Account</th>
<th className="px-3 py-2 text-right font-semibold">YTD (thru {MONTHS[through - 1]})</th>
<th className="px-3 py-2 text-right font-semibold">Monthly Avg (÷{N})</th>
<th className="px-3 py-2 text-right font-semibold w-[110px]">Inflation %</th>
<th className="px-3 py-2 text-right font-semibold">Projected Annual</th>
<th className="px-3 py-2 w-[40px]"></th>
</tr>
</thead>
<tbody>
{rows.map((a) => {
const overridden = (ytdOv[a.id]?.trim() || projOv[a.id]?.trim() || infl[a.id]?.trim());
return (
<tr key={a.id} className="border-b hover:bg-muted/30">
<td className="px-3 py-1.5">
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
</td>
<td className="px-2 py-1 text-right">
<Input className="h-8 text-right tabular-nums" type="number" step="0.01"
value={ytdOv[a.id] ?? ""} placeholder={(ytdComputed[a.id] ?? 0).toFixed(2)}
onChange={(e) => setYtdOv((m) => ({ ...m, [a.id]: e.target.value }))} />
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{money(avgVal(a.id))}</td>
<td className="px-2 py-1 text-right">
<Input className="h-8 text-right tabular-nums" type="number" step="0.1" placeholder="0"
value={infl[a.id] ?? ""} onChange={(e) => setInfl((m) => ({ ...m, [a.id]: e.target.value }))} />
</td>
<td className="px-2 py-1 text-right">
<Input className="h-8 text-right tabular-nums font-medium" type="number" step="0.01"
value={projOv[a.id] ?? ""} placeholder={compProj(a.id).toFixed(2)}
onChange={(e) => setProjOv((m) => ({ ...m, [a.id]: e.target.value }))} />
</td>
<td className="px-1 py-1 text-center">
{overridden ? (
<Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground" title="Reset row to imported values"
onClick={() => resetRow(a.id)}><RotateCcw className="h-3.5 w-3.5" /></Button>
) : null}
</td>
</tr>
);
})}
<tr className="border-t-2 border-primary font-semibold bg-muted/40">
<td className="px-3 py-2">Total {title}</td>
<td className="px-3 py-2 text-right tabular-nums">{money(rows.reduce((s, a) => s + ytdVal(a.id), 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{money(rows.reduce((s, a) => s + avgVal(a.id), 0))}</td>
<td></td>
<td className="px-3 py-2 text-right tabular-nums">{money(sum(rows))}</td>
<td></td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3 justify-between">
<div>
<h1 className="text-2xl font-semibold">Budget Workbook</h1>
<p className="text-sm text-muted-foreground mt-0.5">Build next year's budget from YTD actuals through a chosen month.</p>
</div>
<Select value={selectedAssociation?.id ?? ""} onValueChange={(id) => setSelectedAssociation(sortedAssoc.find((a: any) => a.id === id) ?? null)}>
<SelectTrigger className="w-[240px]">
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue placeholder={loadingAssociations ? "Loading…" : "Select association"} />
</SelectTrigger>
<SelectContent>
{sortedAssoc.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{!associationId ? (
<p className="text-sm text-muted-foreground">Select an association to begin.</p>
) : companyLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : companyError || !companyId ? (
<p className="text-sm text-muted-foreground py-12 text-center">{companyError || "Accounting is not set up for this association."}</p>
) : (
<>
<Card>
<CardContent className="flex flex-wrap items-end gap-4 py-4">
<div>
<Label className="text-xs">Fiscal Year</Label>
<Input type="number" className="h-9 w-[110px]" value={fy} onChange={(e) => setFy(Number(e.target.value) || fy)} />
</div>
<div>
<Label className="text-xs">Through month</Label>
<Select value={String(through)} onValueChange={(v) => setThrough(Number(v))}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
<SelectContent>
{MONTHS.map((m, i) => <SelectItem key={m} value={String(i + 1)}>{m} ({i + 1})</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["wb-gl", cid, from, to] })} disabled={glFetching}>
<RefreshCw className={`h-4 w-4 mr-1 ${glFetching ? "animate-spin" : ""}`} /> Refresh
</Button>
<Button variant="outline" size="sm" onClick={exportCsv}><Download className="h-4 w-4 mr-1" /> CSV</Button>
<Button variant="outline" size="sm" onClick={saveWorkbook} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button>
<Button size="sm" onClick={pushToBudget} disabled={pushing}><Upload className="h-4 w-4 mr-1" /> {pushing ? "Pushing…" : "Push to Budget"}</Button>
</div>
</CardContent>
</Card>
{/* Summary */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Annual Budget (Expenses)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(expenseAnnual)}</div></CardContent></Card>
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Monthly (÷12)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(monthly)}</div></CardContent></Card>
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Per Unit / Month ({unitCount} units)</div><div className="text-xl font-semibold mt-1 tabular-nums">{unitCount > 0 ? money(perUnit) : "—"}</div></CardContent></Card>
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Projected Surplus / (Deficit)</div><div className={`text-xl font-semibold mt-1 tabular-nums ${incomeAnnual - expenseAnnual < 0 ? "text-destructive" : "text-emerald-700"}`}>{money(incomeAnnual - expenseAnnual)}</div></CardContent></Card>
</div>
{sections.exp.length > 0 && renderSection("Expenses", sections.exp)}
{sections.inc.length > 0 && renderSection("Income", sections.inc)}
</>
)}
</div>
);
}