mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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 LegalMattersPage from "./pages/LegalMattersPage";
|
||||||
import ParkingPage from "./pages/ParkingPage";
|
import ParkingPage from "./pages/ParkingPage";
|
||||||
import PaymentPlansPage from "./pages/PaymentPlansPage";
|
import PaymentPlansPage from "./pages/PaymentPlansPage";
|
||||||
import BudgetManagementPage from "./pages/BudgetManagementPage";
|
import BudgetWorkbookPage from "./pages/BudgetWorkbookPage";
|
||||||
import BankAccountsPage from "./pages/BankAccountsPage";
|
import BankAccountsPage from "./pages/BankAccountsPage";
|
||||||
import BankRegisterPage from "./pages/BankRegisterPage";
|
import BankRegisterPage from "./pages/BankRegisterPage";
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ const App = () => (
|
|||||||
<Route path="violations" element={<ViolationsPage />} />
|
<Route path="violations" element={<ViolationsPage />} />
|
||||||
<Route path="announcements" element={<AnnouncementsPage />} />
|
<Route path="announcements" element={<AnnouncementsPage />} />
|
||||||
<Route path="media" element={<MediaLibraryPage />} />
|
<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="budget-management/:id" element={<AccountingBudgetDetailPage basePath="/dashboard/budget-management" />} />
|
||||||
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
|
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
|
||||||
<Route path="bank-accounts-list" element={<BankAccountsPage />} />
|
<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 Overview", url: "/dashboard/financial-overview", icon: PieChart },
|
||||||
{ title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
|
{ title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
|
||||||
{ title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock },
|
{ 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 Overview", url: "/dashboard/financial-overview", icon: PieChart },
|
||||||
{ title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
|
{ title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
|
||||||
{ title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock },
|
{ 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