mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Budget Workbook: replace Budget Management with a YTD-actuals workbook
/dashboard/budget-management now renders a Budget Workbook that pulls YTD actuals (from the accounting GL, through a chosen month), derives a monthly average (YTD/N), takes a per-line inflation %, and projects an annual budget (avg x 12 x (1+infl)). Footer rolls up annual budget / 12 / # units = per-unit monthly assessment. Income + expense sections; all imported fields editable. - Standalone saved worksheet (accounting.budget_workbooks/_lines, RLS like accounts) - "Push to Budget" writes projected/12 into accounting.budgets + budget_entries - Uses accounting.accounts (synced with the Accounting dashboard COA) and paginates the GL fetch (1000-row cap). Nav relabeled Budget Management -> Budget Workbook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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 = () => (
|
||||
<Route path="violations" element={<ViolationsPage />} />
|
||||
<Route path="announcements" element={<AnnouncementsPage />} />
|
||||
<Route path="media" element={<MediaLibraryPage />} />
|
||||
<Route path="budget-management" element={<BudgetManagementPage />} />
|
||||
<Route path="budget-management" element={<BudgetWorkbookPage />} />
|
||||
<Route path="budget-management/:id" element={<AccountingBudgetDetailPage basePath="/dashboard/budget-management" />} />
|
||||
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
|
||||
<Route path="bank-accounts-list" element={<BankAccountsPage />} />
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<any[]> {
|
||||
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<Record<string, string>>({});
|
||||
const [infl, setInfl] = useState<Record<string, string>>({});
|
||||
const [projOv, setProjOv] = useState<Record<string, string>>({});
|
||||
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<string, string> = {}, i: Record<string, string> = {}, p: Record<string, string> = {};
|
||||
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<string, string>((accounts as any[]).map((a) => [a.id, a.type]));
|
||||
const out: Record<string, number> = {};
|
||||
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[]) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-2"><CardTitle className="text-base">{title}</CardTitle></CardHeader>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-y text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">Account</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">YTD (thru {MONTHS[through - 1]})</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Monthly Avg (÷{N})</th>
|
||||
<th className="px-3 py-2 text-right font-semibold w-[110px]">Inflation %</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Projected Annual</th>
|
||||
<th className="px-3 py-2 w-[40px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => {
|
||||
const overridden = (ytdOv[a.id]?.trim() || projOv[a.id]?.trim() || infl[a.id]?.trim());
|
||||
return (
|
||||
<tr key={a.id} className="border-b hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5">
|
||||
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right">
|
||||
<Input className="h-8 text-right tabular-nums" type="number" step="0.01"
|
||||
value={ytdOv[a.id] ?? ""} placeholder={(ytdComputed[a.id] ?? 0).toFixed(2)}
|
||||
onChange={(e) => setYtdOv((m) => ({ ...m, [a.id]: e.target.value }))} />
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">{money(avgVal(a.id))}</td>
|
||||
<td className="px-2 py-1 text-right">
|
||||
<Input className="h-8 text-right tabular-nums" type="number" step="0.1" placeholder="0"
|
||||
value={infl[a.id] ?? ""} onChange={(e) => setInfl((m) => ({ ...m, [a.id]: e.target.value }))} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right">
|
||||
<Input className="h-8 text-right tabular-nums font-medium" type="number" step="0.01"
|
||||
value={projOv[a.id] ?? ""} placeholder={compProj(a.id).toFixed(2)}
|
||||
onChange={(e) => setProjOv((m) => ({ ...m, [a.id]: e.target.value }))} />
|
||||
</td>
|
||||
<td className="px-1 py-1 text-center">
|
||||
{overridden ? (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground" title="Reset row to imported values"
|
||||
onClick={() => resetRow(a.id)}><RotateCcw className="h-3.5 w-3.5" /></Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-t-2 border-primary font-semibold bg-muted/40">
|
||||
<td className="px-3 py-2">Total {title}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{money(rows.reduce((s, a) => s + ytdVal(a.id), 0))}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{money(rows.reduce((s, a) => s + avgVal(a.id), 0))}</td>
|
||||
<td></td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{money(sum(rows))}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3 justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Budget Workbook</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Build next year's budget from YTD actuals through a chosen month.</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{!associationId ? (
|
||||
<p className="text-sm text-muted-foreground">Select an association to begin.</p>
|
||||
) : companyLoading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : companyError || !companyId ? (
|
||||
<p className="text-sm text-muted-foreground py-12 text-center">{companyError || "Accounting is not set up for this association."}</p>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||
<div>
|
||||
<Label className="text-xs">Fiscal Year</Label>
|
||||
<Input type="number" className="h-9 w-[110px]" value={fy} onChange={(e) => setFy(Number(e.target.value) || fy)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Through month</Label>
|
||||
<Select value={String(through)} onValueChange={(v) => setThrough(Number(v))}>
|
||||
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((m, i) => <SelectItem key={m} value={String(i + 1)}>{m} ({i + 1})</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["wb-gl", cid, from, to] })} disabled={glFetching}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${glFetching ? "animate-spin" : ""}`} /> Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportCsv}><Download className="h-4 w-4 mr-1" /> CSV</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveWorkbook} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button>
|
||||
<Button size="sm" onClick={pushToBudget} disabled={pushing}><Upload className="h-4 w-4 mr-1" /> {pushing ? "Pushing…" : "Push to Budget"}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<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">Annual Budget (Expenses)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(expenseAnnual)}</div></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Monthly (÷12)</div><div className="text-xl font-semibold mt-1 tabular-nums">{money(monthly)}</div></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Per Unit / Month ({unitCount} units)</div><div className="text-xl font-semibold mt-1 tabular-nums">{unitCount > 0 ? money(perUnit) : "—"}</div></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><div className="text-xs text-muted-foreground uppercase tracking-wide">Projected Surplus / (Deficit)</div><div className={`text-xl font-semibold mt-1 tabular-nums ${incomeAnnual - expenseAnnual < 0 ? "text-destructive" : "text-emerald-700"}`}>{money(incomeAnnual - expenseAnnual)}</div></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{sections.exp.length > 0 && renderSection("Expenses", sections.exp)}
|
||||
{sections.inc.length > 0 && renderSection("Income", sections.inc)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user