mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
e302fb91f0
- Remove the Zoho Books integration (edge functions, sync libs, settings, reports/overview, banking links, fees tab, import dialog); preserve fee rules as a standalone FeesTab and the COA accounting_system classification. - Financial Overview/Reports (staff + board) render the Accounting dashboard and reports; board reports mirror the rich Accounting Reports. - New Reserve Fund Schedule report + an is_reserve flag on accounts. - Unify all report exports to a branded format (logo + centered header + footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs Actuals and Bank Reconciliation PDFs now match the reference layout. - Render financial reports inline (no preview pop-up). - Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA navigation; editable bills in the Accounting Bills page. - Negative opening balances flow through to the GL and reports (allow negative input; keep non-zero on save; signed CSV import). - Upload a per-account trial balance via CSV on Opening Balances. - Board members: read-only RLS access to their association's accounting ledger; editable board-members panel on the association page; share vendor contacts with the board (toggle + directory section). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
445 lines
20 KiB
TypeScript
445 lines
20 KiB
TypeScript
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { accounting } from "@/lib/accountingClient";
|
|
import { useCompanyId } from "./lib/useCompanyId";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ArrowLeft, ChevronDown, ChevronRight, MoveRight, Wand2, Save, CheckCircle2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { money } from "./lib/format";
|
|
|
|
const MONTHS = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
|
|
const QUARTERS = ["Q1","Q2","Q3","Q4"];
|
|
|
|
function periodLabels(p: string) {
|
|
if (p === "monthly") return MONTHS;
|
|
if (p === "quarterly") return QUARTERS;
|
|
return ["Year"];
|
|
}
|
|
|
|
type Grid = Record<string, string[]>;
|
|
|
|
export default function AccountingBudgetDetailPage({ basePath = "/dashboard/accounting/budgets" }: { basePath?: string } = {}) {
|
|
const { id = "" } = useParams();
|
|
const { companyId } = useCompanyId();
|
|
const cid = companyId ?? "";
|
|
const cur = "USD";
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
const [grid, setGrid] = useState<Grid>({});
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({ income: true, expense: true });
|
|
const [hideZero, setHideZero] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [tab, setTab] = useState<"summary" | "edit">("summary");
|
|
const [summarySection, setSummarySection] = useState<"income" | "expense">("income");
|
|
|
|
const { data: budget, isLoading: bLoading } = useQuery({
|
|
queryKey: ["budget", id],
|
|
enabled: !!id,
|
|
queryFn: async () => {
|
|
const { data, error } = await accounting.from("budgets").select("*").eq("id", id).single();
|
|
if (error) console.error("Budget query error:", error);
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const { data: accounts = [] } = useQuery({
|
|
queryKey: ["accounts", cid],
|
|
enabled: !!cid,
|
|
queryFn: async () =>
|
|
(await accounting.from("accounts").select("*").eq("company_id", cid).order("code")).data ?? [],
|
|
});
|
|
|
|
const { data: existing = [] } = useQuery({
|
|
queryKey: ["budget-entries", id],
|
|
enabled: !!id,
|
|
queryFn: async () =>
|
|
(await accounting.from("budget_entries").select("*").eq("budget_id", id)).data ?? [],
|
|
});
|
|
|
|
const periods = useMemo(() => periodLabels(budget?.period_type ?? "monthly"), [budget?.period_type]);
|
|
const periodCount = periods.length;
|
|
|
|
useEffect(() => {
|
|
if (!(accounts as any[]).length || !budget) return;
|
|
const next: Grid = {};
|
|
for (const a of accounts as any[]) {
|
|
next[a.id] = Array.from({ length: periodCount }, () => "");
|
|
}
|
|
for (const e of existing as any[]) {
|
|
if (next[e.account_id] !== undefined && e.period_index < periodCount) {
|
|
next[e.account_id][e.period_index] = String(e.amount);
|
|
}
|
|
}
|
|
setGrid(next);
|
|
}, [(accounts as any[]).length, (existing as any[]).length, budget?.id, periodCount]);
|
|
|
|
const grouped = useMemo(() => {
|
|
const out: Record<string, any[]> = {};
|
|
for (const a of accounts as any[]) {
|
|
if (!out[a.type]) out[a.type] = [];
|
|
out[a.type].push(a);
|
|
}
|
|
return out;
|
|
}, [accounts]);
|
|
|
|
const rowSum = (aid: string) => (grid[aid] ?? []).reduce((s, v) => s + (parseFloat(v) || 0), 0);
|
|
const rowPeriod = (aid: string, i: number) => parseFloat(grid[aid]?.[i] ?? "") || 0;
|
|
const groupPeriod = (type: string, i: number) =>
|
|
(grouped[type] ?? []).reduce((s: number, a: any) => s + rowPeriod(a.id, i), 0);
|
|
const groupTotal = (type: string) =>
|
|
(grouped[type] ?? []).reduce((s: number, a: any) => s + rowSum(a.id), 0);
|
|
|
|
const updateCell = (aid: string, idx: number, val: string) => {
|
|
setGrid(prev => {
|
|
const row = [...(prev[aid] ?? Array.from({ length: periodCount }, () => ""))];
|
|
row[idx] = val;
|
|
return { ...prev, [aid]: row };
|
|
});
|
|
};
|
|
|
|
// Distribute a FY total evenly across all periods
|
|
const distributeFYTotal = (aid: string, total: number) => {
|
|
if (!total || periodCount === 0) return;
|
|
const perPeriod = (total / periodCount).toFixed(2);
|
|
setGrid(prev => ({ ...prev, [aid]: Array.from({ length: periodCount }, () => perPeriod) }));
|
|
};
|
|
|
|
const fillRow = (aid: string) => {
|
|
const first = (grid[aid] ?? [])[0];
|
|
if (!first) return toast.error("Enter Jan first");
|
|
setGrid(prev => ({ ...prev, [aid]: Array.from({ length: periodCount }, () => first) }));
|
|
};
|
|
|
|
const autoFill = async () => {
|
|
if (!budget) return;
|
|
const year = budget.fiscal_year - 1;
|
|
const [{ data: inv }, { data: exp }] = await Promise.all([
|
|
accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid)
|
|
.gte("issue_date", `${year}-01-01`).lte("issue_date", `${year}-12-31`),
|
|
accounting.from("expenses").select("amount,category,date").eq("company_id", cid)
|
|
.gte("date", `${year}-01-01`).lte("date", `${year}-12-31`),
|
|
]);
|
|
const next: Grid = {};
|
|
for (const a of accounts as any[]) {
|
|
next[a.id] = Array.from({ length: periodCount }, () => "");
|
|
}
|
|
const incomeAccs = grouped.income ?? [];
|
|
const expAccs = grouped.expense ?? [];
|
|
for (const i of inv ?? []) {
|
|
if (i.status !== "paid") continue;
|
|
const m = new Date(i.issue_date).getMonth();
|
|
const b = budget.period_type === "monthly" ? m : budget.period_type === "quarterly" ? Math.floor(m / 3) : 0;
|
|
const share = Number(i.total) / Math.max(incomeAccs.length, 1);
|
|
for (const a of incomeAccs) {
|
|
next[a.id][b] = String((parseFloat(next[a.id][b]) || 0) + share);
|
|
}
|
|
}
|
|
for (const e of exp ?? []) {
|
|
const m = new Date(e.date).getMonth();
|
|
const b = budget.period_type === "monthly" ? m : budget.period_type === "quarterly" ? Math.floor(m / 3) : 0;
|
|
const match = expAccs.find((a: any) => a.name.toLowerCase() === String(e.category).toLowerCase());
|
|
if (match) next[match.id][b] = String((parseFloat(next[match.id][b]) || 0) + Number(e.amount));
|
|
}
|
|
const formatted: Grid = {};
|
|
for (const [k, v] of Object.entries(next)) {
|
|
formatted[k] = v.map(n => parseFloat(n) ? parseFloat(n).toFixed(2) : "");
|
|
}
|
|
setGrid(formatted);
|
|
toast.success(`Pre-filled from ${year} actuals`);
|
|
};
|
|
|
|
const save = async (activate: boolean) => {
|
|
setSaving(true);
|
|
await accounting.from("budget_entries").delete().eq("budget_id", id);
|
|
const rows: any[] = [];
|
|
for (const [aid, vals] of Object.entries(grid)) {
|
|
vals.forEach((v, idx) => {
|
|
const n = parseFloat(v);
|
|
if (!isNaN(n) && n !== 0) rows.push({ budget_id: id, account_id: aid, period_index: idx, amount: n });
|
|
});
|
|
}
|
|
if (rows.length) {
|
|
const { error } = await accounting.from("budget_entries").insert(rows);
|
|
if (error) { setSaving(false); return toast.error(error.message); }
|
|
}
|
|
await accounting.from("budgets").update({ status: activate ? "active" : "draft" }).eq("id", id);
|
|
setSaving(false);
|
|
toast.success(activate ? "Budget activated" : "Saved");
|
|
qc.invalidateQueries({ queryKey: ["budget-entries", id] });
|
|
qc.invalidateQueries({ queryKey: ["budgets", cid] });
|
|
if (activate) navigate(basePath);
|
|
};
|
|
|
|
if (bLoading) {
|
|
return <div className="p-6 text-muted-foreground">Loading budget…</div>;
|
|
}
|
|
|
|
if (!budget) {
|
|
return (
|
|
<div className="p-6 space-y-2">
|
|
<div className="text-muted-foreground">Budget not found (ID: {id})</div>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to={basePath}><ArrowLeft className="mr-1 h-4 w-4" /> Back to Budgets</Link>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const incomeTotal = groupTotal("income");
|
|
const expenseTotal = groupTotal("expense");
|
|
const netIncome = incomeTotal - expenseTotal;
|
|
const fyLabel = `FY${budget.fiscal_year}`;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link to={basePath}><ArrowLeft className="mr-1 h-4 w-4" /> Budgets</Link>
|
|
</Button>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-xl font-semibold">{budget.name}</h1>
|
|
<Badge variant={budget.status === "active" ? "default" : "secondary"}>
|
|
{budget.status === "active" ? "Active" : "Draft"}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">FY {budget.fiscal_year} · {budget.period_type}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => save(false)} disabled={saving}>
|
|
<Save className="h-4 w-4 mr-1" /> Save Draft
|
|
</Button>
|
|
<Button size="sm" onClick={() => save(true)} disabled={saving}>
|
|
<CheckCircle2 className="h-4 w-4 mr-1" /> Activate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab buttons (plain, no Tabs component) */}
|
|
<div className="flex border-b">
|
|
{(["summary", "edit"] as const).map(t => (
|
|
<button key={t} onClick={() => setTab(t)}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px capitalize transition-colors ${tab === t ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
|
|
{t === "summary" ? "Summary" : "Edit Budget"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
{tab === "summary" && (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardContent className="p-0 overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-muted/50 border-b">
|
|
<th className="text-left font-semibold py-2.5 px-4 min-w-[160px]">TOTALS</th>
|
|
{periods.map(p => <th key={p} className="text-right font-semibold py-2.5 px-3 min-w-[68px]">{p}</th>)}
|
|
<th className="text-right font-bold py-2.5 px-4 min-w-[90px] bg-muted/80">{fyLabel}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr className="border-b">
|
|
<td className="py-2.5 px-4 font-medium">Expense</td>
|
|
{periods.map((_, i) => <td key={i} className="py-2.5 px-3 text-right tabular-nums">{groupPeriod("expense", i).toFixed(2)}</td>)}
|
|
<td className="py-2.5 px-4 text-right font-semibold bg-muted/30">{money(expenseTotal, cur)}</td>
|
|
</tr>
|
|
<tr className="border-b">
|
|
<td className="py-2.5 px-4 font-medium">Income</td>
|
|
{periods.map((_, i) => <td key={i} className="py-2.5 px-3 text-right tabular-nums">{groupPeriod("income", i).toFixed(2)}</td>)}
|
|
<td className="py-2.5 px-4 text-right font-semibold bg-muted/30">{money(incomeTotal, cur)}</td>
|
|
</tr>
|
|
<tr className="bg-muted/30 font-bold">
|
|
<td className="py-2.5 px-4">Net Income</td>
|
|
{periods.map((_, i) => {
|
|
const net = groupPeriod("income", i) - groupPeriod("expense", i);
|
|
return <td key={i} className={`py-2.5 px-3 text-right tabular-nums ${net < 0 ? "text-red-600" : ""}`}>{net.toFixed(2)}</td>;
|
|
})}
|
|
<td className={`py-2.5 px-4 text-right font-bold bg-muted/50 ${netIncome < 0 ? "text-red-600" : ""}`}>{money(netIncome, cur)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex border-b">
|
|
{(["income", "expense"] as const).map(s => (
|
|
<button key={s} onClick={() => setSummarySection(s)}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px capitalize transition-colors ${summarySection === s ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
|
|
{s === "income" ? "Income" : "Expenses"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0 overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-muted/50 border-b">
|
|
<th className="text-left font-semibold py-2.5 px-4 min-w-[200px]">{summarySection.toUpperCase()}</th>
|
|
{periods.map(p => <th key={p} className="text-right font-semibold py-2.5 px-3 min-w-[68px]">{p}</th>)}
|
|
<th className="text-right font-bold py-2.5 px-4 min-w-[90px] bg-muted/80">{fyLabel}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(grouped[summarySection] ?? []).map((a: any) => {
|
|
const total = rowSum(a.id);
|
|
return (
|
|
<tr key={a.id} className="border-b hover:bg-muted/20">
|
|
<td className="py-2.5 px-4 font-semibold">{a.code ? `${a.code} ${a.name}` : a.name}</td>
|
|
{periods.map((_, i) => (
|
|
<td key={i} className="py-2.5 px-3 text-right tabular-nums">
|
|
{rowPeriod(a.id, i) > 0 ? rowPeriod(a.id, i).toFixed(2) : "—"}
|
|
</td>
|
|
))}
|
|
<td className="py-2.5 px-4 text-right font-semibold bg-muted/30">{money(total, cur)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{(grouped[summarySection] ?? []).length === 0 && (
|
|
<tr>
|
|
<td colSpan={periods.length + 2} className="text-center text-muted-foreground py-6 text-sm">
|
|
No {summarySection} accounts yet. Add them in Chart of Accounts.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit */}
|
|
{tab === "edit" && (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardContent className="flex flex-wrap items-center gap-4 py-3">
|
|
<Button variant="outline" size="sm" onClick={autoFill}>
|
|
<Wand2 className="mr-1 h-4 w-4" /> Auto-fill from Prior Year
|
|
</Button>
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Switch id="hz" checked={hideZero} onCheckedChange={setHideZero} />
|
|
<Label htmlFor="hz" className="text-sm">Hide zero accounts</Label>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{(["income", "expense"] as const).map(type => {
|
|
const accs = grouped[type] ?? [];
|
|
const isOpen = expanded[type];
|
|
return (
|
|
<Card key={type}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/40 cursor-pointer"
|
|
onClick={() => setExpanded(p => ({ ...p, [type]: !p[type] }))}>
|
|
<div className="flex items-center gap-2 font-semibold">
|
|
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
{type === "income" ? "Income" : "Expenses"}
|
|
<span className="text-xs font-normal text-muted-foreground ml-1">({accs.length})</span>
|
|
</div>
|
|
<div className="text-sm font-semibold">{money(groupTotal(type), cur)}</div>
|
|
</div>
|
|
{isOpen && (
|
|
<CardContent className="p-0 overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-muted/20 border-b">
|
|
<th className="text-left font-medium py-2 px-4 min-w-[200px]">Account</th>
|
|
{periods.map(p => <th key={p} className="text-right font-medium py-2 px-2 min-w-[72px]">{p}</th>)}
|
|
<th className="text-right font-medium py-2 px-4 min-w-[88px] bg-muted/40">{fyLabel}</th>
|
|
<th className="w-8"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{accs.map((a: any) => {
|
|
const total = rowSum(a.id);
|
|
if (hideZero && total === 0) return null;
|
|
return (
|
|
<tr key={a.id} className="border-b hover:bg-muted/10">
|
|
<td className="py-1.5 px-4 font-medium">{a.code ? `${a.code} ${a.name}` : a.name}</td>
|
|
{periods.map((_, idx) => (
|
|
<td key={idx} className="py-1 px-1">
|
|
<Input
|
|
type="number"
|
|
inputMode="decimal"
|
|
value={grid[a.id]?.[idx] ?? ""}
|
|
onChange={e => updateCell(a.id, idx, e.target.value)}
|
|
className="h-7 text-right tabular-nums px-2 text-xs"
|
|
placeholder="0.00"
|
|
/>
|
|
</td>
|
|
))}
|
|
<td className="py-1 px-1 bg-muted/20">
|
|
<Input
|
|
type="number"
|
|
inputMode="decimal"
|
|
placeholder={money(total, cur)}
|
|
className="h-7 text-right tabular-nums px-2 text-xs bg-muted/10 border-dashed"
|
|
title="Enter FY total to distribute evenly across all periods"
|
|
onBlur={(e) => {
|
|
const v = parseFloat(e.target.value);
|
|
if (v > 0) { distributeFYTotal(a.id, v); e.target.value = ""; }
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
const v = parseFloat((e.target as HTMLInputElement).value);
|
|
if (v > 0) { distributeFYTotal(a.id, v); (e.target as HTMLInputElement).value = ""; }
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="px-1">
|
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => fillRow(a.id)}>
|
|
<MoveRight className="h-3 w-3" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{accs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={periods.length + 3} className="text-center text-muted-foreground py-4 text-xs">
|
|
No accounts. Add them in Chart of Accounts first.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{accs.length > 0 && (
|
|
<tr className="bg-muted/30 font-semibold border-t">
|
|
<td className="py-2 px-4">Total</td>
|
|
{periods.map((_, i) => (
|
|
<td key={i} className="py-2 px-2 text-right tabular-nums text-xs">{money(groupPeriod(type, i), cur)}</td>
|
|
))}
|
|
<td className="py-2 px-4 text-right tabular-nums bg-muted/50">{money(groupTotal(type), cur)}</td>
|
|
<td></td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
);
|
|
})}
|
|
|
|
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-6 py-3 font-semibold">
|
|
<span>Net Income</span>
|
|
<span className={netIncome < 0 ? "text-red-600" : ""}>{money(netIncome, cur)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|