diff --git a/src/App.tsx b/src/App.tsx index 199821c..8fbf1a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -104,6 +104,7 @@ import LegalMattersPage from "./pages/LegalMattersPage"; import ParkingPage from "./pages/ParkingPage"; import PaymentPlansPage from "./pages/PaymentPlansPage"; import BudgetWorkbookPage from "./pages/BudgetWorkbookPage"; +import ReserveWorkbookPage from "./pages/ReserveWorkbookPage"; import BankAccountsPage from "./pages/BankAccountsPage"; import BankRegisterPage from "./pages/BankRegisterPage"; @@ -362,6 +363,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/dashboard/AppSidebar.tsx b/src/components/dashboard/AppSidebar.tsx index b476f04..95fb957 100644 --- a/src/components/dashboard/AppSidebar.tsx +++ b/src/components/dashboard/AppSidebar.tsx @@ -133,6 +133,7 @@ const sections: SectionDef[] = [ { title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 }, { title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock }, { title: "Budget Workbook", url: "/dashboard/budget-management", icon: BarChart3 }, + { title: "Reserves Workbook", url: "/dashboard/reserve-workbook", icon: BarChart3 }, ], }, { diff --git a/src/components/dashboard/DashboardTopNav.tsx b/src/components/dashboard/DashboardTopNav.tsx index 9f143d8..666803d 100644 --- a/src/components/dashboard/DashboardTopNav.tsx +++ b/src/components/dashboard/DashboardTopNav.tsx @@ -161,6 +161,7 @@ const megaMenuSections: MegaMenuSection[] = [ { title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 }, { title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock }, { title: "Budget Workbook", url: "/dashboard/budget-management", icon: BarChart3 }, + { title: "Reserves Workbook", url: "/dashboard/reserve-workbook", icon: BarChart3 }, ], }, { diff --git a/src/pages/ReserveWorkbookPage.tsx b/src/pages/ReserveWorkbookPage.tsx new file mode 100644 index 0000000..8d85c92 --- /dev/null +++ b/src/pages/ReserveWorkbookPage.tsx @@ -0,0 +1,424 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import { Building2, Loader2, Save, Plus, Trash2, FileText, ChevronLeft, ChevronRight, CheckCircle2, RefreshCw } 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 PROJ_YEARS = 30; +const PRELIM_NOTE = "These projections are for preliminary purposes only and are not subject to annual increases or inflation."; + +type Comp = { + id: string; category: string; name: string; quantity: string; cost_per_unit: string; + useful_life: string; remaining_life: string; beginning_balance: string; notes: string; +}; +const blankComp = (): Comp => ({ + id: crypto.randomUUID(), category: "", name: "", quantity: "1", cost_per_unit: "0", + useful_life: "1", remaining_life: "0", beginning_balance: "0", notes: "", +}); +const num = (s: string) => Number(s) || 0; + +// Page through GL lines (PostgREST caps at 1000). +async function fetchReserveGL(cid: string, ids: string[], from: string, to: string) { + if (!ids.length) return []; + const PAGE = 1000; const out: any[] = []; + for (let offset = 0; ; offset += PAGE) { + const { data, error } = await accounting.from("journal_entry_lines") + .select("debit,credit,account_id,journal_entries!inner(company_id,date)") + .eq("journal_entries.company_id", cid).in("account_id", ids) + .gte("journal_entries.date", from).lte("journal_entries.date", to) + .range(offset, offset + PAGE - 1); + if (error) throw error; + const rows = (data ?? []) as any[]; out.push(...rows); + if (rows.length < PAGE) break; + } + return out; +} + +export default function ReserveWorkbookPage() { + const qc = useQueryClient(); + const { associations, selectedAssociation, setSelectedAssociation, loadingAssociations } = useAssociation() as any; + const { companyId, loading: companyLoading, error: companyError } = useCompanyId(); + const cid = companyId ?? ""; + + const [fy, setFy] = useState(new Date().getFullYear()); + const [starting, setStarting] = useState("0"); + const [inflation, setInflation] = useState("3"); + const [interest, setInterest] = useState("4"); + const [target, setTarget] = useState("100"); + const [contrib, setContrib] = useState("0"); + const [contribChange, setContribChange] = useState("0"); + const [comps, setComps] = useState([]); + const [saving, setSaving] = useState(false); + const [certifying, setCertifying] = useState(false); + const [certified, setCertified] = useState(false); + const [certifiedAt, setCertifiedAt] = useState(null); + + const sortedAssoc = useMemo(() => [...(associations ?? [])].sort((a: any, b: any) => a.name.localeCompare(b.name)), [associations]); + const assocIdx = sortedAssoc.findIndex((a: any) => a.id === selectedAssociation?.id); + const stepAssoc = (delta: number) => { + if (!sortedAssoc.length) return; + const next = assocIdx < 0 ? 0 : (assocIdx + delta + sortedAssoc.length) % sortedAssoc.length; + setSelectedAssociation(sortedAssoc[next]); + }; + + // Reserve-flagged COA accounts drive the autoloaded balance + expenditures. + const { data: reserveAccts = [] } = useQuery({ + queryKey: ["rw-reserve-accts", cid], + enabled: !!cid, + queryFn: async () => + (await accounting.from("accounts").select("id,name,code,type,balance") + .eq("company_id", cid).eq("is_reserve", true).eq("is_archived", false)).data ?? [], + }); + + const { data: reserveGL = [], isFetching: glFetching } = useQuery({ + queryKey: ["rw-reserve-gl", cid], + enabled: !!cid && reserveAccts.length > 0, + queryFn: () => fetchReserveGL(cid, (reserveAccts as any[]).map((a) => a.id), "2000-01-01", `${fy + 1}-12-31`), + }); + + // Persisted workbook + const { data: workbook } = useQuery({ + queryKey: ["rw-state", cid, fy], + enabled: !!cid, + queryFn: async () => { + const { data: head } = await accounting.from("reserve_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("reserve_components") + .select("*").eq("workbook_id", (head as any).id).order("sort_order"); + return { head, lines: lines ?? [] }; + }, + }); + + useEffect(() => { + if (!workbook) return; + const h = workbook.head as any; + if (h) { + setStarting(String(h.starting_balance ?? 0)); setInflation(String(h.inflation_pct ?? 3)); + setInterest(String(h.interest_pct ?? 4)); setTarget(String(h.funding_target_pct ?? 100)); + setContrib(String(h.annual_contribution ?? 0)); setContribChange(String(h.contribution_change_pct ?? 0)); + setCertified(!!h.certified); setCertifiedAt(h.certified_at ?? null); + } else { setCertified(false); setCertifiedAt(null); } + setComps(((workbook.lines as any[]) ?? []).map((l) => ({ + id: l.id, category: l.category ?? "", name: l.name ?? "", quantity: String(l.quantity ?? 1), + cost_per_unit: String(l.cost_per_unit ?? 0), useful_life: String(l.useful_life ?? 1), + remaining_life: String(l.remaining_life ?? 0), beginning_balance: String(l.beginning_balance ?? 0), notes: l.notes ?? "", + }))); + }, [workbook]); + + // --- Autoloaded actuals from the reserve GL accounts --- + const acctType = useMemo(() => new Map((reserveAccts as any[]).map((a) => [a.id, a.type])), [reserveAccts]); + const reserveFundBalance = useMemo(() => { + // Net of reserve ASSET accounts = current reserve cash on hand. + let bal = 0; + for (const a of reserveAccts as any[]) if (a.type === "asset") bal += Number(a.balance) || 0; + return bal; + }, [reserveAccts]); + const actualExpByYear = useMemo(() => { + // Reserve expenditures = debits on reserve EXPENSE accounts (money spent from reserves), by year. + const out: Record = {}; + for (const l of reserveGL as any[]) { + if (acctType.get(l.account_id) !== "expense") continue; + const y = Number((l.journal_entries?.date ?? "").slice(0, 4)); + if (!y) continue; + out[y] = (out[y] ?? 0) + ((Number(l.debit) || 0) - (Number(l.credit) || 0)); + } + return out; + }, [reserveGL, acctType]); + + // --- Per-component computed reserve-study values --- + const inflFrac = num(inflation) / 100; + const compCalc = (c: Comp) => { + const total = num(c.quantity) * num(c.cost_per_unit); + const ul = Math.max(1, num(c.useful_life)); + const rl = Math.max(0, num(c.remaining_life)); + const inflated = total * Math.pow(1 + inflFrac, rl); // cost at replacement + const replacementYear = fy + rl; + const ageFrac = Math.min(1, Math.max(0, (ul - rl) / ul)); + const fullyFunded = total * ageFrac; // ideal balance today + const bb = num(c.beginning_balance); + const pctFunded = fullyFunded > 0 ? bb / fullyFunded : 0; + return { total, ul, rl, inflated, replacementYear, fullyFunded, pctFunded }; + }; + const totals = useMemo(() => { + let total = 0, inflated = 0, ffb = 0, bb = 0; + for (const c of comps) { const k = compCalc(c); total += k.total; inflated += k.inflated; ffb += k.fullyFunded; bb += num(c.beginning_balance); } + return { total, inflated, ffb, bb, pct: ffb > 0 ? bb / ffb : 0 }; + }, [comps, inflation, fy]); + + // --- 30-year projection --- + const projection = useMemo(() => { + const rows: any[] = []; + let bal = num(starting); + const iFrac = num(interest) / 100, cFrac = num(contribChange) / 100; + for (let y = 0; y < PROJ_YEARS; y++) { + const Y = fy + y; + const start = bal; + const contribution = num(contrib) * Math.pow(1 + cFrac, y); + const interestInc = start * iFrac; + // scheduled replacements this year + actual GL expenditures already recorded + let scheduled = 0, ffbY = 0; + for (const c of comps) { + const { total, ul, rl } = compCalc(c); + const inflatedNow = total * Math.pow(1 + inflFrac, y); + if (y - rl >= 0 && (y - rl) % ul === 0) scheduled += inflatedNow; + const effAge = (((ul - rl) + y) % ul); + ffbY += inflatedNow * (effAge / ul); + } + const actual = actualExpByYear[Y] ?? 0; + const expenses = Y <= new Date().getFullYear() ? Math.max(actual, scheduled === 0 ? actual : scheduled) : scheduled; + const end = start + contribution + interestInc - expenses; + rows.push({ year: Y, start, contribution, interest: interestInc, expenses, end, ffb: ffbY, pct: ffbY > 0 ? end / ffbY : 0, actual }); + bal = end; + } + return rows; + }, [starting, interest, contrib, contribChange, comps, inflation, fy, actualExpByYear]); + + const setComp = (id: string, k: keyof Comp, v: string) => setComps((m) => m.map((c) => c.id === id ? { ...c, [k]: v } : c)); + + const save = async () => { + if (!cid) return; + setSaving(true); + try { + const { data: head, error: hErr } = await accounting.from("reserve_workbooks").upsert({ + company_id: cid, fiscal_year: fy, starting_balance: num(starting), inflation_pct: num(inflation), + interest_pct: num(interest), funding_target_pct: num(target), annual_contribution: num(contrib), + contribution_change_pct: num(contribChange), 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("reserve_components").delete().eq("workbook_id", (head as any).id); + if (comps.length) { + const rows = comps.map((c, i) => ({ + workbook_id: (head as any).id, company_id: cid, category: c.category, name: c.name, + quantity: num(c.quantity), cost_per_unit: num(c.cost_per_unit), useful_life: Math.max(1, num(c.useful_life)), + remaining_life: Math.max(0, num(c.remaining_life)), beginning_balance: num(c.beginning_balance), + in_service_year: fy - (Math.max(1, num(c.useful_life)) - Math.max(0, num(c.remaining_life))), notes: c.notes || null, sort_order: i, + })); + const { error } = await accounting.from("reserve_components").insert(rows); + if (error) throw new Error(error.message); + } + toast.success("Reserve workbook saved"); + qc.invalidateQueries({ queryKey: ["rw-state", cid, fy] }); + } catch (e: any) { toast.error(e.message || "Failed to save"); } + finally { setSaving(false); } + }; + + const toggleCertify = async () => { + if (!cid) return; + const next = !certified; + if (next && !confirm(`Certify the FY${fy} reserve plan as approved? This removes the preliminary disclaimer.`)) return; + setCertifying(true); + try { + const { data: u } = await supabase.auth.getUser(); + const { error } = await accounting.from("reserve_workbooks").upsert({ + company_id: cid, fiscal_year: fy, starting_balance: num(starting), inflation_pct: num(inflation), + interest_pct: num(interest), funding_target_pct: num(target), annual_contribution: num(contrib), + contribution_change_pct: num(contribChange), certified: next, + certified_at: next ? new Date().toISOString() : null, certified_by: next ? (u?.user?.id ?? null) : null, + updated_at: new Date().toISOString(), + }, { onConflict: "company_id,fiscal_year" }); + if (error) throw new Error(error.message); + setCertified(next); setCertifiedAt(next ? new Date().toISOString() : null); + toast.success(next ? "Reserve plan certified as approved" : "Reverted to preliminary"); + qc.invalidateQueries({ queryKey: ["rw-state", cid, fy] }); + } catch (e: any) { toast.error(e.message || "Failed to update certification"); } + finally { setCertifying(false); } + }; + + const exportPdf = () => { + const doc = new jsPDF({ orientation: "landscape", unit: "pt", format: "letter" }); + const pageW = doc.internal.pageSize.getWidth(); const margin = 40; const contentW = pageW - margin * 2; + const assocName = selectedAssociation?.name ?? "Association"; + doc.setFillColor(30, 41, 59); doc.rect(0, 0, pageW, 64, "F"); + doc.setTextColor(255, 255, 255); doc.setFontSize(18); doc.setFont("helvetica", "bold"); + doc.text("CAPITAL RESERVE ANALYSIS", margin, 34); + doc.setFontSize(10); doc.setFont("helvetica", "normal"); + doc.text(`${assocName} · ${fy} Budget Year`, margin, 52); + let y = 84; + if (certified) { doc.setTextColor(22, 101, 52); doc.setFont("helvetica", "bold"); doc.setFontSize(10); + doc.text(`APPROVED RESERVE PLAN${certifiedAt ? ` — Certified ${new Date(certifiedAt).toLocaleDateString()}` : ""}`, margin, y); y += 18; } + else { doc.setTextColor(220, 38, 38); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); + const ls = doc.splitTextToSize(PRELIM_NOTE, contentW); doc.text(ls, margin, y); y += ls.length * 12 + 8; } + doc.setTextColor(30, 30, 30); + autoTable(doc, { + startY: y, + head: [["Component", "Qty", "Cost/Unit", "Total", "Useful", "Remaining", "Replace", "Beginning Bal", "Fully Funded", "% Funded"]], + body: comps.map((c) => { const k = compCalc(c); return [ + `${c.category ? c.category + " — " : ""}${c.name}`, c.quantity, money(num(c.cost_per_unit)), money(k.total), + k.ul, k.rl, k.replacementYear, money(num(c.beginning_balance)), money(k.fullyFunded), `${Math.round(k.pctFunded * 100)}%`, + ]; }), + foot: [["Total", "", "", money(totals.total), "", "", "", money(totals.bb), money(totals.ffb), `${Math.round(totals.pct * 100)}%`]], + theme: "grid", styles: { fontSize: 7.5, cellPadding: 3 }, headStyles: { fillColor: [30, 41, 59] }, footStyles: { fillColor: [241, 245, 249], textColor: 30, fontStyle: "bold" }, + margin: { left: margin, right: margin }, + }); + y = (doc as any).lastAutoTable.finalY + 18; + if (y > doc.internal.pageSize.getHeight() - 120) { doc.addPage(); y = margin; } + doc.setFontSize(11); doc.setFont("helvetica", "bold"); doc.setTextColor(30, 30, 30); + doc.text("Reserve Plan Summary — 30 Year Projection", margin, y); y += 8; + autoTable(doc, { + startY: y, + head: [["Year", "Starting Bal", "Contribution", "Interest", "Expenses", "Ending Bal", "Fully Funded", "% Funded"]], + body: projection.map((r) => [r.year, money(r.start), money(r.contribution), money(r.interest), money(r.expenses), money(r.end), money(r.ffb), `${Math.round(r.pct * 100)}%`]), + theme: "grid", styles: { fontSize: 7.5, cellPadding: 2.5 }, headStyles: { fillColor: [30, 41, 59] }, margin: { left: margin, right: margin }, + }); + doc.save(`Reserve-Analysis-${assocName.replace(/[^a-z0-9]/gi, "_")}-${fy}.pdf`); + }; + + return ( +
+
+
+

Reserves Workbook

+

Capital reserve analysis with a 30-year funding projection. Reserve balance & expenditures auto-load from the GL.

+
+
+ +
+ + {sortedAssoc.length > 0 && assocIdx >= 0 && Client {assocIdx + 1} of {sortedAssoc.length}} +
+ +
+
+ + {!cid && !companyLoading ? ( +

{companyError || "Select an association with accounting set up."}

+ ) : companyLoading ? ( +
+ ) : ( + <> + + +
setFy(Number(e.target.value) || fy)} />
+
setStarting(e.target.value)} /> +
+
setContrib(e.target.value)} />
+
setContribChange(e.target.value)} />
+
setInflation(e.target.value)} />
+
setInterest(e.target.value)} />
+
setTarget(e.target.value)} />
+
+ + + + +
+
+
+ + {certified ? ( +
+ Approved reserve plan. Certified{certifiedAt ? ` on ${new Date(certifiedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}` : ""}. +
+ ) : ( +

{PRELIM_NOTE}

+ )} + + {/* Summary cards */} +
+
Reserve Balance (GL)
{money(reserveFundBalance)}
+
Fully Funded Balance
{money(totals.ffb)}
+
% Funded
{Math.round(totals.pct * 100)}%
+
Replacement Cost (today)
{money(totals.total)}
+
+ + {/* Components */} + + + Reserve Components + + + + + + + + + + + + + + {comps.map((c) => { const k = compCalc(c); return ( + + + + + + + + + + + + + + + ); })} + {comps.length === 0 && } + {comps.length > 0 && ( + + + + + + + + + + )} + +
CategoryComponentQtyCost/UnitTotalUsefulRemainingReplaceBeginning BalFully Funded% Funded
setComp(c.id, "category", e.target.value)} placeholder="Entry Gates" /> setComp(c.id, "name", e.target.value)} placeholder="Gate Operators" /> setComp(c.id, "quantity", e.target.value)} /> setComp(c.id, "cost_per_unit", e.target.value)} />{money(k.total)} setComp(c.id, "useful_life", e.target.value)} /> setComp(c.id, "remaining_life", e.target.value)} />{k.replacementYear} setComp(c.id, "beginning_balance", e.target.value)} />{money(k.fullyFunded)}= 1 ? "text-emerald-700" : ""}`}>{Math.round(k.pctFunded * 100)}%
No components yet. Click "Add Component" to build the reserve study.
Total{money(totals.total)}{money(totals.bb)}{money(totals.ffb)}{Math.round(totals.pct * 100)}%
+
+
+ + {/* 30-year projection */} + + Reserve Plan Summary — {PROJ_YEARS} Year Projection + + + + + + + + + + {projection.map((r) => ( + + + + + + + + + + + ))} + +
YearStarting BalanceContributionInterestExpensesEnding BalanceFully Funded% Funded
{r.year}{r.actual ? : null}{money(r.start)}{money(r.contribution)}{money(r.interest)}{money(r.expenses)}{money(r.end)}{money(r.ffb)}= 1 ? "text-emerald-700" : ""}`}>{Math.round(r.pct * 100)}%
+

Expenses use scheduled component replacements; years with a • include actual reserve expenditures loaded from the GL.

+
+
+ + )} +
+ ); +}