Reserve Workbook: add Reserve Spending tab (auto-imported, real-time)

Wraps the page in tabs: Workbook (assumptions/components/projection) and
a new Reserve Spending tab. The spending tab auto-imports every GL line
on reserve-flagged (is_reserve) expense accounts — total + reserve
balance, spending-by-year, and a full imported-expenditure detail list
(date, account, description, ref, amount). It shares the live GL query
so it feeds the projection's actual expenses in real time; Re-import
button refreshes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 23:04:47 -04:00
parent af9e092cbd
commit 3f0c7ba1bb
+112 -4
View File
@@ -8,12 +8,13 @@ import { useAssociation } from "@/contexts/AssociationContext";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { accounting } from "@/lib/accountingClient"; import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "@/pages/accounting/lib/useCompanyId"; import { useCompanyId } from "@/pages/accounting/lib/useCompanyId";
import { money } from "@/pages/accounting/lib/format"; import { money, fmtDate } from "@/pages/accounting/lib/format";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PROJ_YEARS = 30; const PROJ_YEARS = 30;
const PRELIM_NOTE = "These projections are for preliminary purposes only and are not subject to annual increases or inflation."; const PRELIM_NOTE = "These projections are for preliminary purposes only and are not subject to annual increases or inflation.";
@@ -34,7 +35,7 @@ async function fetchReserveGL(cid: string, ids: string[], from: string, to: stri
const PAGE = 1000; const out: any[] = []; const PAGE = 1000; const out: any[] = [];
for (let offset = 0; ; offset += PAGE) { for (let offset = 0; ; offset += PAGE) {
const { data, error } = await accounting.from("journal_entry_lines") const { data, error } = await accounting.from("journal_entry_lines")
.select("debit,credit,account_id,journal_entries!inner(company_id,date)") .select("debit,credit,account_id,journal_entries!inner(company_id,date,description,reference)")
.eq("journal_entries.company_id", cid).in("account_id", ids) .eq("journal_entries.company_id", cid).in("account_id", ids)
.gte("journal_entries.date", from).lte("journal_entries.date", to) .gte("journal_entries.date", from).lte("journal_entries.date", to)
.range(offset, offset + PAGE - 1); .range(offset, offset + PAGE - 1);
@@ -137,6 +138,26 @@ export default function ReserveWorkbookPage() {
return out; return out;
}, [reserveGL, acctType]); }, [reserveGL, acctType]);
// Detailed reserve expenditures (each GL line on a reserve EXPENSE account),
// newest first — drives the "Reserve Spending" tab. Net = debit - credit.
const acctName = useMemo(() => new Map<string, string>((reserveAccts as any[]).map((a) => [a.id, `${a.code ? a.code + " " : ""}${a.name}`])), [reserveAccts]);
const reserveSpending = useMemo(() => {
const rows = (reserveGL as any[])
.filter((l) => acctType.get(l.account_id) === "expense")
.map((l) => ({
date: l.journal_entries?.date ?? "",
account: acctName.get(l.account_id) ?? "—",
description: l.journal_entries?.description ?? "",
reference: l.journal_entries?.reference ?? "",
amount: (Number(l.debit) || 0) - (Number(l.credit) || 0),
}))
.filter((r) => Math.abs(r.amount) > 0.004)
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const total = rows.reduce((s, r) => s + r.amount, 0);
const byYear = Object.entries(actualExpByYear).map(([y, amt]) => ({ year: Number(y), amount: amt })).sort((a, b) => b.year - a.year);
return { rows, total, byYear };
}, [reserveGL, acctType, acctName, actualExpByYear]);
// --- Per-component computed reserve-study values --- // --- Per-component computed reserve-study values ---
const inflFrac = num(inflation) / 100; const inflFrac = num(inflation) / 100;
const compCalc = (c: Comp) => { const compCalc = (c: Comp) => {
@@ -300,7 +321,13 @@ export default function ReserveWorkbookPage() {
) : companyLoading ? ( ) : companyLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div> <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : ( ) : (
<> <Tabs defaultValue="workbook" className="space-y-4">
<TabsList>
<TabsTrigger value="workbook">Workbook</TabsTrigger>
<TabsTrigger value="spending">Reserve Spending {reserveSpending.rows.length > 0 ? `(${reserveSpending.rows.length})` : ""}</TabsTrigger>
</TabsList>
<TabsContent value="workbook" className="space-y-4 mt-0">
<Card> <Card>
<CardContent className="flex flex-wrap items-end gap-4 py-4"> <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">Budget Year</Label><Input type="number" className="h-9 w-[100px]" value={fy} onChange={(e) => setFy(Number(e.target.value) || fy)} /></div>
@@ -417,7 +444,88 @@ export default function ReserveWorkbookPage() {
<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> <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> </CardContent>
</Card> </Card>
</> </TabsContent>
{/* ── Reserve Spending: auto-imported from the GL, real-time ── */}
<TabsContent value="spending" className="space-y-4 mt-0">
<Card>
<CardContent className="flex flex-wrap items-center gap-4 py-4">
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wide">Total Reserve Spending (imported)</div>
<div className="text-xl font-semibold mt-1 tabular-nums">{money(reserveSpending.total)}</div>
</div>
<div>
<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>
</div>
<div className="ml-auto">
<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" : ""}`} /> {glFetching ? "Importing…" : "Re-import"}
</Button>
</div>
</CardContent>
</Card>
<div className="rounded-md border border-blue-200 bg-blue-50 px-4 py-2.5 text-sm text-blue-800">
Reserve spending is auto-imported from every <strong>reserve-flagged</strong> (is_reserve) expense account in the GL it flows into the projection's actual expenses in real time. Flag accounts as reserves in the Chart of Accounts to include them here.
</div>
{/* By year */}
<Card>
<CardHeader className="py-3 bg-muted/40 border-b"><CardTitle className="text-sm font-semibold">Spending by Year</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">Reserve Expenditures</th>
</tr></thead>
<tbody>
{reserveSpending.byYear.length === 0 ? (
<tr><td colSpan={2} className="text-center text-muted-foreground py-8">No reserve expenditures found. Flag reserve expense accounts as is_reserve in the Chart of Accounts.</td></tr>
) : reserveSpending.byYear.map((r) => (
<tr key={r.year} className="border-b hover:bg-muted/20">
<td className="px-3 py-1.5">{r.year}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(r.amount)}</td>
</tr>
))}
{reserveSpending.byYear.length > 0 && (
<tr className="border-t-2 border-primary font-semibold bg-muted/40">
<td className="px-3 py-2">Total</td>
<td className="px-3 py-2 text-right tabular-nums">{money(reserveSpending.total)}</td>
</tr>
)}
</tbody>
</table>
</CardContent>
</Card>
{/* Detail */}
<Card>
<CardHeader className="py-3 bg-muted/40 border-b"><CardTitle className="text-sm font-semibold">Imported Expenditures ({reserveSpending.rows.length})</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">Date</th><th className="px-3 py-2 text-left">Account</th>
<th className="px-3 py-2 text-left">Description</th><th className="px-3 py-2 text-left">Ref</th>
<th className="px-3 py-2 text-right">Amount</th>
</tr></thead>
<tbody>
{reserveSpending.rows.length === 0 ? (
<tr><td colSpan={5} className="text-center text-muted-foreground py-8">No reserve expenditures imported.</td></tr>
) : reserveSpending.rows.map((r, i) => (
<tr key={i} className="border-b hover:bg-muted/20">
<td className="px-3 py-1.5 whitespace-nowrap">{r.date ? fmtDate(r.date) : "—"}</td>
<td className="px-3 py-1.5">{r.account}</td>
<td className="px-3 py-1.5 text-muted-foreground">{r.description || "—"}</td>
<td className="px-3 py-1.5 text-muted-foreground">{r.reference || "—"}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(r.amount)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)} )}
</div> </div>
); );