mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user