diff --git a/src/App.tsx b/src/App.tsx index 67b38a8..93c5397 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => ( } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/dashboard/AppSidebar.tsx b/src/components/dashboard/AppSidebar.tsx index b3370a1..b476f04 100644 --- a/src/components/dashboard/AppSidebar.tsx +++ b/src/components/dashboard/AppSidebar.tsx @@ -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 }, ], }, { diff --git a/src/components/dashboard/DashboardTopNav.tsx b/src/components/dashboard/DashboardTopNav.tsx index 7e86b6f..9f143d8 100644 --- a/src/components/dashboard/DashboardTopNav.tsx +++ b/src/components/dashboard/DashboardTopNav.tsx @@ -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 }, ], }, { diff --git a/src/pages/BudgetWorkbookPage.tsx b/src/pages/BudgetWorkbookPage.tsx new file mode 100644 index 0000000..0397040 --- /dev/null +++ b/src/pages/BudgetWorkbookPage.tsx @@ -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 { + 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>({}); + const [infl, setInfl] = useState>({}); + const [projOv, setProjOv] = useState>({}); + 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 = {}, i: Record = {}, p: Record = {}; + 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((accounts as any[]).map((a) => [a.id, a.type])); + const out: Record = {}; + 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[]) => ( + + {title} + + + + + + + + + + + + + + {rows.map((a) => { + const overridden = (ytdOv[a.id]?.trim() || projOv[a.id]?.trim() || infl[a.id]?.trim()); + return ( + + + + + + + + + ); + })} + + + + + + + + + +
AccountYTD (thru {MONTHS[through - 1]})Monthly Avg (÷{N})Inflation %Projected Annual
+ {a.code && {a.code}}{a.name} + + setYtdOv((m) => ({ ...m, [a.id]: e.target.value }))} /> + {money(avgVal(a.id))} + setInfl((m) => ({ ...m, [a.id]: e.target.value }))} /> + + setProjOv((m) => ({ ...m, [a.id]: e.target.value }))} /> + + {overridden ? ( + + ) : null} +
Total {title}{money(rows.reduce((s, a) => s + ytdVal(a.id), 0))}{money(rows.reduce((s, a) => s + avgVal(a.id), 0))}{money(sum(rows))}
+
+
+ ); + + return ( +
+
+
+

Budget Workbook

+

Build next year's budget from YTD actuals through a chosen month.

+
+ +
+ + {!associationId ? ( +

Select an association to begin.

+ ) : companyLoading ? ( +
+ ) : companyError || !companyId ? ( +

{companyError || "Accounting is not set up for this association."}

+ ) : ( + <> + + +
+ + setFy(Number(e.target.value) || fy)} /> +
+
+ + +
+
+ + + + +
+
+
+ + {/* Summary */} +
+
Annual Budget (Expenses)
{money(expenseAnnual)}
+
Monthly (÷12)
{money(monthly)}
+
Per Unit / Month ({unitCount} units)
{unitCount > 0 ? money(perUnit) : "—"}
+
Projected Surplus / (Deficit)
{money(incomeAnnual - expenseAnnual)}
+
+ + {sections.exp.length > 0 && renderSection("Expenses", sections.exp)} + {sections.inc.length > 0 && renderSection("Income", sections.inc)} + + )} +
+ ); +} diff --git a/supabase/migrations/20260609170000_budget_workbook.sql b/supabase/migrations/20260609170000_budget_workbook.sql new file mode 100644 index 0000000..02baec7 --- /dev/null +++ b/supabase/migrations/20260609170000_budget_workbook.sql @@ -0,0 +1,41 @@ +-- Budget Workbook: standalone worksheet state (chosen month + per-line inflation % +-- and overrides) for the Budget Workbook page. Computed values come from the GL at +-- runtime; only the user's edits are persisted here. Company-scoped, RLS mirrors +-- accounting.accounts (accounting staff OR company member). + +create table if not exists accounting.budget_workbooks ( + id uuid primary key default gen_random_uuid(), + company_id uuid not null references accounting.companies(id) on delete cascade, + fiscal_year int not null, + through_month int not null default 1, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (company_id, fiscal_year) +); + +create table if not exists accounting.budget_workbook_lines ( + id uuid primary key default gen_random_uuid(), + workbook_id uuid not null references accounting.budget_workbooks(id) on delete cascade, + account_id uuid not null references accounting.accounts(id) on delete cascade, + ytd_override numeric, + inflation_pct numeric, + projected_override numeric, + unique (workbook_id, account_id) +); + +alter table accounting.budget_workbooks enable row level security; +alter table accounting.budget_workbook_lines enable row level security; + +create policy "wb staff" on accounting.budget_workbooks for all to authenticated + using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff()); +create policy "wb members" on accounting.budget_workbooks for all to authenticated + using (accounting.is_company_member(company_id, auth.uid())) with check (accounting.is_company_member(company_id, auth.uid())); + +create policy "wbl staff" on accounting.budget_workbook_lines for all to authenticated + using (accounting.is_accounting_staff()) with check (accounting.is_accounting_staff()); +create policy "wbl members" on accounting.budget_workbook_lines for all to authenticated + using (exists (select 1 from accounting.budget_workbooks w where w.id = workbook_id and accounting.is_company_member(w.company_id, auth.uid()))) + with check (exists (select 1 from accounting.budget_workbooks w where w.id = workbook_id and accounting.is_company_member(w.company_id, auth.uid()))); + +grant select, insert, update, delete on accounting.budget_workbooks to authenticated; +grant select, insert, update, delete on accounting.budget_workbook_lines to authenticated;