Add Reserves Workbook (capital reserve analysis + 30yr projection)

New /dashboard/reserve-workbook: per-association capital reserve study.
- Manually-entered components with computed total, inflated replacement
  cost, replacement year, fully-funded balance, % funded.
- Autoloads the current reserve fund balance and actual reserve
  expenditures by year from reserve-flagged (is_reserve) GL accounts.
- 30-year Reserve Plan Summary projection (starting balance,
  contribution w/ change %, interest, scheduled+actual expenses, ending
  balance, fully-funded, % funded) with inflation/interest assumptions.
- Certify-as-approved + bold red preliminary disclaimer (same pattern as
  the budget workbook); Save + landscape PDF export.
New accounting.reserve_workbooks / reserve_components tables (RLS mirrors
budget_workbooks). Nav links added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:36:07 -04:00
parent 7aedc6d90d
commit fa4ee3e215
4 changed files with 428 additions and 0 deletions
+2
View File
@@ -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 = () => (
<Route path="announcements" element={<AnnouncementsPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="budget-management" element={<BudgetWorkbookPage />} />
<Route path="reserve-workbook" element={<ReserveWorkbookPage />} />
<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
View File
@@ -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 },
],
},
{
@@ -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 },
],
},
{
+424
View File
@@ -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<Comp[]>([]);
const [saving, setSaving] = useState(false);
const [certifying, setCertifying] = useState(false);
const [certified, setCertified] = useState(false);
const [certifiedAt, setCertifiedAt] = useState<string | null>(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<string, string>((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<number, number> = {};
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3 justify-between">
<div>
<h1 className="text-2xl font-semibold">Reserves Workbook</h1>
<p className="text-sm text-muted-foreground mt-0.5">Capital reserve analysis with a 30-year funding projection. Reserve balance &amp; expenditures auto-load from the GL.</p>
</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="icon" className="h-10 w-10 shrink-0" title="Previous client" onClick={() => stepAssoc(-1)} disabled={sortedAssoc.length <= 1}><ChevronLeft className="h-4 w-4" /></Button>
<div className="flex flex-col">
<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>
{sortedAssoc.length > 0 && assocIdx >= 0 && <span className="text-[11px] text-muted-foreground text-center mt-1">Client {assocIdx + 1} of {sortedAssoc.length}</span>}
</div>
<Button variant="outline" size="icon" className="h-10 w-10 shrink-0" title="Next client" onClick={() => stepAssoc(1)} disabled={sortedAssoc.length <= 1}><ChevronRight className="h-4 w-4" /></Button>
</div>
</div>
{!cid && !companyLoading ? (
<p className="text-sm text-muted-foreground py-12 text-center">{companyError || "Select an association with accounting set up."}</p>
) : companyLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<>
<Card>
<CardContent className="flex flex-wrap items-end gap-4 py-4">
<div><Label className="text-xs">Budget Year</Label><Input type="number" className="h-9 w-[100px]" value={fy} onChange={(e) => setFy(Number(e.target.value) || fy)} /></div>
<div><Label className="text-xs">Starting Balance</Label><Input type="number" className="h-9 w-[130px]" value={starting} onChange={(e) => setStarting(e.target.value)} />
<button type="button" className="text-[11px] text-primary hover:underline mt-0.5" onClick={() => setStarting(String(Math.round(reserveFundBalance * 100) / 100))}>Use GL balance ({money(reserveFundBalance)})</button></div>
<div><Label className="text-xs">Annual Contribution</Label><Input type="number" className="h-9 w-[130px]" value={contrib} onChange={(e) => setContrib(e.target.value)} /></div>
<div><Label className="text-xs">Contribution Change %</Label><Input type="number" step="0.1" className="h-9 w-[110px]" value={contribChange} onChange={(e) => setContribChange(e.target.value)} /></div>
<div><Label className="text-xs">Inflation %</Label><Input type="number" step="0.1" className="h-9 w-[90px]" value={inflation} onChange={(e) => setInflation(e.target.value)} /></div>
<div><Label className="text-xs">Interest %</Label><Input type="number" step="0.1" className="h-9 w-[90px]" value={interest} onChange={(e) => setInterest(e.target.value)} /></div>
<div><Label className="text-xs">Funding Target %</Label><Input type="number" step="1" className="h-9 w-[100px]" value={target} onChange={(e) => setTarget(e.target.value)} /></div>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["rw-reserve-gl", cid] })} disabled={glFetching}><RefreshCw className={`h-4 w-4 mr-1 ${glFetching ? "animate-spin" : ""}`} /> Refresh</Button>
<Button variant="outline" size="sm" onClick={exportPdf}><FileText className="h-4 w-4 mr-1" /> PDF</Button>
<Button variant="outline" size="sm" onClick={save} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button>
<Button size="sm" variant={certified ? "outline" : "default"} className={certified ? "" : "bg-emerald-600 hover:bg-emerald-700"} onClick={toggleCertify} disabled={certifying}>
<CheckCircle2 className="h-4 w-4 mr-1" /> {certifying ? "Saving…" : certified ? "Certified ✓" : "Certify as Approved"}
</Button>
</div>
</CardContent>
</Card>
{certified ? (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-4 py-2.5 text-sm text-emerald-800 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0" /><span><strong>Approved reserve plan.</strong> Certified{certifiedAt ? ` on ${new Date(certifiedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}` : ""}.</span>
</div>
) : (
<div className="rounded-md border-2 border-red-500 bg-red-50 px-4 py-3 text-center"><p className="font-bold text-red-600">{PRELIM_NOTE}</p></div>
)}
{/* Summary cards */}
<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">Reserve Balance (GL)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(reserveFundBalance)}</div></CardContent></Card>
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Fully Funded Balance</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(totals.ffb)}</div></CardContent></Card>
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">% Funded</div><div className="text-xl font-semibold mt-1 tabular-nums">{Math.round(totals.pct * 100)}%</div></CardContent></Card>
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Replacement Cost (today)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(totals.total)}</div></CardContent></Card>
</div>
{/* Components */}
<Card>
<CardHeader className="flex-row items-center justify-between py-3 bg-muted/40 border-b">
<CardTitle className="text-sm font-semibold">Reserve Components</CardTitle>
<Button size="sm" variant="outline" className="h-8 gap-1" onClick={() => setComps((m) => [...m, blankComp()])}><Plus className="h-3 w-3" /> Add Component</Button>
</CardHeader>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-2 py-2 text-left">Category</th><th className="px-2 py-2 text-left">Component</th>
<th className="px-2 py-2 text-right">Qty</th><th className="px-2 py-2 text-right">Cost/Unit</th>
<th className="px-2 py-2 text-right">Total</th><th className="px-2 py-2 text-right">Useful</th>
<th className="px-2 py-2 text-right">Remaining</th><th className="px-2 py-2 text-right">Replace</th>
<th className="px-2 py-2 text-right">Beginning Bal</th><th className="px-2 py-2 text-right">Fully Funded</th>
<th className="px-2 py-2 text-right">% Funded</th><th className="px-2 py-2"></th>
</tr></thead>
<tbody>
{comps.map((c) => { const k = compCalc(c); return (
<tr key={c.id} className="border-b hover:bg-muted/20">
<td className="px-1 py-1"><Input className="h-8 w-[120px]" value={c.category} onChange={(e) => setComp(c.id, "category", e.target.value)} placeholder="Entry Gates" /></td>
<td className="px-1 py-1"><Input className="h-8 w-[160px]" value={c.name} onChange={(e) => setComp(c.id, "name", e.target.value)} placeholder="Gate Operators" /></td>
<td className="px-1 py-1"><Input className="h-8 w-[64px] text-right" type="number" value={c.quantity} onChange={(e) => setComp(c.id, "quantity", e.target.value)} /></td>
<td className="px-1 py-1"><Input className="h-8 w-[90px] text-right" type="number" value={c.cost_per_unit} onChange={(e) => setComp(c.id, "cost_per_unit", e.target.value)} /></td>
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">{money(k.total)}</td>
<td className="px-1 py-1"><Input className="h-8 w-[60px] text-right" type="number" value={c.useful_life} onChange={(e) => setComp(c.id, "useful_life", e.target.value)} /></td>
<td className="px-1 py-1"><Input className="h-8 w-[60px] text-right" type="number" value={c.remaining_life} onChange={(e) => setComp(c.id, "remaining_life", e.target.value)} /></td>
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">{k.replacementYear}</td>
<td className="px-1 py-1"><Input className="h-8 w-[100px] text-right" type="number" value={c.beginning_balance} onChange={(e) => setComp(c.id, "beginning_balance", e.target.value)} /></td>
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">{money(k.fullyFunded)}</td>
<td className={`px-2 py-1 text-right tabular-nums font-medium ${k.pctFunded < 0.7 ? "text-red-600" : k.pctFunded >= 1 ? "text-emerald-700" : ""}`}>{Math.round(k.pctFunded * 100)}%</td>
<td className="px-1 py-1 text-center"><Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => setComps((m) => m.filter((x) => x.id !== c.id))}><Trash2 className="h-3.5 w-3.5" /></Button></td>
</tr>
); })}
{comps.length === 0 && <tr><td colSpan={12} className="text-center text-muted-foreground py-8">No components yet. Click "Add Component" to build the reserve study.</td></tr>}
{comps.length > 0 && (
<tr className="border-t-2 border-primary font-semibold bg-muted/40">
<td className="px-2 py-2" colSpan={4}>Total</td>
<td className="px-2 py-2 text-right tabular-nums">{money(totals.total)}</td>
<td colSpan={3}></td>
<td className="px-2 py-2 text-right tabular-nums">{money(totals.bb)}</td>
<td className="px-2 py-2 text-right tabular-nums">{money(totals.ffb)}</td>
<td className="px-2 py-2 text-right tabular-nums">{Math.round(totals.pct * 100)}%</td>
<td></td>
</tr>
)}
</tbody>
</table>
</CardContent>
</Card>
{/* 30-year projection */}
<Card>
<CardHeader className="py-3 bg-muted/40 border-b"><CardTitle className="text-sm font-semibold">Reserve Plan Summary {PROJ_YEARS} Year Projection</CardTitle></CardHeader>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 text-left">Year</th><th className="px-3 py-2 text-right">Starting Balance</th>
<th className="px-3 py-2 text-right">Contribution</th><th className="px-3 py-2 text-right">Interest</th>
<th className="px-3 py-2 text-right">Expenses</th><th className="px-3 py-2 text-right">Ending Balance</th>
<th className="px-3 py-2 text-right">Fully Funded</th><th className="px-3 py-2 text-right">% Funded</th>
</tr></thead>
<tbody>
{projection.map((r) => (
<tr key={r.year} className="border-b hover:bg-muted/20">
<td className="px-3 py-1.5">{r.year}{r.actual ? <span className="ml-1 text-[10px] text-primary" title="Includes actual GL expenditures"></span> : null}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(r.start)}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(r.contribution)}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(r.interest)}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(r.expenses)}</td>
<td className={`px-3 py-1.5 text-right tabular-nums font-medium ${r.end < 0 ? "text-red-600" : ""}`}>{money(r.end)}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{money(r.ffb)}</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${r.pct < 0.7 ? "text-red-600" : r.pct >= 1 ? "text-emerald-700" : ""}`}>{Math.round(r.pct * 100)}%</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-muted-foreground px-3 py-2">Expenses use scheduled component replacements; years with a include actual reserve expenditures loaded from the GL.</p>
</CardContent>
</Card>
</>
)}
</div>
);
}