mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Income Statement: Buildium-style category subgroups
Group the multi-period Income Statement by account category (Operating Income, Administration, Utilities, Reserves Budget, …) with "Total for <category>" subtotals, matching the Buildium layout, in the on-screen table, PDF, and CSV. - New accounting.accounts.category column (nullable; null = ungrouped), seeded from the local chart_of_accounts parent hierarchy. - Editable in Chart of Accounts: single-edit (with datalist autocomplete) and bulk-edit (blank = no change, __clear__ to unset). - buildium-account-categories edge function pulls each account's parent-GL category from Buildium (matched by code, fallback name) and backfills accounting.accounts.category; idempotent and re-runnable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
const [type, setType] = useState<string>("asset");
|
||||
const [isBank, setIsBank] = useState(false);
|
||||
const [isReserve, setIsReserve] = useState(false);
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [parentId, setParentId] = useState<string>("");
|
||||
|
||||
@@ -63,6 +64,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
parent_account_id: "no_change",
|
||||
is_bank: "no_change",
|
||||
is_reserve: "no_change",
|
||||
category: "",
|
||||
});
|
||||
|
||||
// ── Opening balances state ──
|
||||
@@ -139,10 +141,16 @@ export default function AccountingChartOfAccountsPage() {
|
||||
return out;
|
||||
}, [accounts]);
|
||||
|
||||
// Existing category names (for the Income Statement subgroups) — used as autocomplete suggestions.
|
||||
const existingCategories = useMemo(
|
||||
() => [...new Set((accounts as any[]).map((a) => a.category).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b)),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
// ── Accounts actions ──
|
||||
const resetForm = () => {
|
||||
setEditId(null); setName(""); setCode(""); setType("asset");
|
||||
setIsBank(false); setIsReserve(false); setDescription(""); setParentId("");
|
||||
setIsBank(false); setIsReserve(false); setCategory(""); setDescription(""); setParentId("");
|
||||
};
|
||||
|
||||
const openEdit = (a: any) => {
|
||||
@@ -152,6 +160,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
setType(a.type ?? "asset");
|
||||
setIsBank(a.is_bank ?? false);
|
||||
setIsReserve(a.is_reserve ?? false);
|
||||
setCategory(a.category ?? "");
|
||||
setDescription(a.description ?? "");
|
||||
setParentId(a.parent_account_id ?? "");
|
||||
setOpen(true);
|
||||
@@ -165,6 +174,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
type: type as any,
|
||||
is_bank: isBank,
|
||||
is_reserve: isReserve,
|
||||
category: category.trim() || null,
|
||||
description: description || null,
|
||||
parent_account_id: parentId || null,
|
||||
};
|
||||
@@ -207,7 +217,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const openBulkEdit = () => {
|
||||
setBulkEdit({ type: "no_change", parent_account_id: "no_change", is_bank: "no_change", is_reserve: "no_change" });
|
||||
setBulkEdit({ type: "no_change", parent_account_id: "no_change", is_bank: "no_change", is_reserve: "no_change", category: "" });
|
||||
setBulkEditOpen(true);
|
||||
};
|
||||
|
||||
@@ -221,6 +231,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
patch.parent_account_id = bulkEdit.parent_account_id === "none" ? null : bulkEdit.parent_account_id;
|
||||
if (bulkEdit.is_bank !== "no_change") patch.is_bank = bulkEdit.is_bank === "true";
|
||||
if (bulkEdit.is_reserve !== "no_change") patch.is_reserve = bulkEdit.is_reserve === "true";
|
||||
if (bulkEdit.category.trim()) patch.category = bulkEdit.category.trim() === "__clear__" ? null : bulkEdit.category.trim();
|
||||
|
||||
if (Object.keys(patch).length === 0) return toast.error("No changes selected");
|
||||
if (patch.parent_account_id && ids.includes(patch.parent_account_id))
|
||||
@@ -407,6 +418,13 @@ export default function AccountingChartOfAccountsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Category <span className="text-muted-foreground text-xs font-normal">(Income Statement subgroup — e.g. "Administration", "Utilities")</span></Label>
|
||||
<Input list="coa-category-suggestions" maxLength={80} value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Ungrouped" />
|
||||
<datalist id="coa-category-suggestions">
|
||||
{existingCategories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div><Label>Description</Label><Textarea maxLength={500} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional notes about this account" /></div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isBank} onChange={(e) => setIsBank(e.target.checked)} />
|
||||
@@ -485,6 +503,10 @@ export default function AccountingChartOfAccountsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Category <span className="text-muted-foreground text-xs font-normal">(Income Statement subgroup)</span></Label>
|
||||
<Input list="coa-category-suggestions" value={bulkEdit.category} onChange={(e) => setBulkEdit((p) => ({ ...p, category: e.target.value }))} placeholder="Blank = no change · type __clear__ to unset" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBulkEditOpen(false)}>Cancel</Button>
|
||||
|
||||
@@ -703,7 +703,34 @@ function isNum(n: number): string {
|
||||
return v < 0 ? `(${abs})` : abs;
|
||||
}
|
||||
|
||||
type ISAcct = { id: string; code: string | null; name: string; type: string; by: Map<string, number>; total: number };
|
||||
type ISAcct = { id: string; code: string | null; name: string; type: string; category: string | null; by: Map<string, number>; total: number };
|
||||
type ISTotal = { by: Map<string, number>; total: number };
|
||||
type ISGroup = { name: string | null; accts: ISAcct[] } & ISTotal;
|
||||
|
||||
const isByCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name);
|
||||
|
||||
function isSumRows(rows: { by: Map<string, number>; total: number }[], periods: { key: string }[]): ISTotal {
|
||||
const by = new Map<string, number>(); let total = 0;
|
||||
for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0));
|
||||
for (const r of rows) total += r.total;
|
||||
return { by, total };
|
||||
}
|
||||
|
||||
/** Group accounts by their `category` into Buildium-style subgroups; ungrouped (null) sorts last. */
|
||||
function isGroupAccts(accts: ISAcct[], periods: { key: string }[]): ISGroup[] {
|
||||
const map = new Map<string, ISAcct[]>();
|
||||
for (const a of accts) {
|
||||
const k = (a.category ?? "").trim();
|
||||
(map.get(k) ?? map.set(k, []).get(k)!).push(a);
|
||||
}
|
||||
const groups: ISGroup[] = [];
|
||||
for (const [name, list] of map) {
|
||||
list.sort(isByCode);
|
||||
groups.push({ name: name || null, accts: list, ...isSumRows(list, periods) });
|
||||
}
|
||||
groups.sort((a, b) => (a.name === null ? 1 : 0) - (b.name === null ? 1 : 0) || String(a.name).localeCompare(String(b.name)));
|
||||
return groups;
|
||||
}
|
||||
|
||||
function IncomeStatementReport({ companyId, companyName, from, to, currency, logoUrl }: {
|
||||
companyId: string; companyName: string; from: string; to: string; currency: string; logoUrl?: string | null;
|
||||
@@ -715,7 +742,7 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
enabled: !!companyId,
|
||||
queryFn: () => fetchAllGLLines(
|
||||
companyId, to,
|
||||
"id,debit,credit,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)",
|
||||
"id,debit,credit,accounts!inner(id,name,code,type,category),journal_entries!inner(company_id,date)",
|
||||
from,
|
||||
),
|
||||
});
|
||||
@@ -731,31 +758,26 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
|
||||
const amt = type === "income" ? credit - debit : debit - credit; // both shown positive
|
||||
let rec = accts.get(a.id);
|
||||
if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, by: new Map(), total: 0 }; accts.set(a.id, rec); }
|
||||
if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, category: a.category ?? null, by: new Map(), total: 0 }; accts.set(a.id, rec); }
|
||||
const key = isPeriodKey(date, gran);
|
||||
rec.by.set(key, (rec.by.get(key) ?? 0) + amt);
|
||||
rec.total += amt;
|
||||
}
|
||||
const byCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name);
|
||||
const live = [...accts.values()].filter((a) => Math.abs(a.total) > 0.005);
|
||||
const income = live.filter((a) => a.type === "income").sort(byCode);
|
||||
const expense = live.filter((a) => a.type === "expense").sort(byCode);
|
||||
const sumRow = (rows: ISAcct[]) => {
|
||||
const by = new Map<string, number>(); let total = 0;
|
||||
for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0));
|
||||
for (const r of rows) total += r.total;
|
||||
return { by, total };
|
||||
};
|
||||
const incTot = sumRow(income), expTot = sumRow(expense);
|
||||
const net = {
|
||||
const income = live.filter((a) => a.type === "income");
|
||||
const expense = live.filter((a) => a.type === "expense");
|
||||
const incomeGroups = isGroupAccts(income, periods);
|
||||
const expenseGroups = isGroupAccts(expense, periods);
|
||||
const incTot = isSumRows(income, periods), expTot = isSumRows(expense, periods);
|
||||
const net: ISTotal = {
|
||||
by: new Map(periods.map((p) => [p.key, (incTot.by.get(p.key) ?? 0) - (expTot.by.get(p.key) ?? 0)])),
|
||||
total: incTot.total - expTot.total,
|
||||
};
|
||||
return { income, expense, incTot, expTot, net };
|
||||
return { incomeGroups, expenseGroups, incTot, expTot, net, hasRows: income.length > 0 || expense.length > 0 };
|
||||
}, [glLines, periods, gran]);
|
||||
|
||||
const subtitle = `${fmtDate(from)} – ${fmtDate(to)}, By ${gran[0].toUpperCase()}${gran.slice(1)}, Accrual basis`;
|
||||
const hasRows = model.income.length > 0 || model.expense.length > 0;
|
||||
const { hasRows } = model;
|
||||
|
||||
const exportPdf = async () => {
|
||||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||||
@@ -766,27 +788,33 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
});
|
||||
const head = [["Account", ...periods.map((p) => p.label), "Total"]];
|
||||
const body: any[] = [];
|
||||
const boldRows = new Set<number>();
|
||||
const sectionRows = new Set<number>();
|
||||
const pushRow = (label: string, by: Map<string, number> | null, total: number | null, kind: "section" | "account" | "total") => {
|
||||
const fillRows = new Set<number>(); // section headers (Income / Expense)
|
||||
const boldRows = new Set<number>(); // group headers, subtotals, totals, net
|
||||
const pushRow = (label: string, t: ISTotal | null, kind: "section" | "group" | "account" | "subtotal" | "total") => {
|
||||
const asMoney = kind === "subtotal" || kind === "total";
|
||||
const cells = [
|
||||
label,
|
||||
...periods.map((p) => (by ? (kind === "total" ? money(by.get(p.key) ?? 0, currency) : isNum(by.get(p.key) ?? 0)) : "")),
|
||||
total == null ? "" : (kind === "total" ? money(total, currency) : isNum(total)),
|
||||
...periods.map((p) => (t ? (asMoney ? money(t.by.get(p.key) ?? 0, currency) : isNum(t.by.get(p.key) ?? 0)) : "")),
|
||||
t == null ? "" : (asMoney ? money(t.total, currency) : isNum(t.total)),
|
||||
];
|
||||
if (kind === "total") boldRows.add(body.length);
|
||||
if (kind === "section") sectionRows.add(body.length);
|
||||
if (kind === "section") fillRows.add(body.length);
|
||||
if (kind !== "account") boldRows.add(body.length);
|
||||
body.push(cells);
|
||||
};
|
||||
pushRow("Income", null, null, "section");
|
||||
for (const a of model.income) pushRow(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total, "account");
|
||||
pushRow("Total Income", model.incTot.by, model.incTot.total, "total");
|
||||
pushRow("Expense", null, null, "section");
|
||||
for (const a of model.expense) pushRow(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total, "account");
|
||||
pushRow("Total Expense", model.expTot.by, model.expTot.total, "total");
|
||||
pushRow("Net Income", model.net.by, model.net.total, "total");
|
||||
const emitSection = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
|
||||
pushRow(title, null, "section");
|
||||
for (const g of groups) {
|
||||
if (g.name) pushRow(` ${g.name}`, null, "group");
|
||||
for (const a of g.accts) pushRow(`${g.name ? " " : " "}${a.code ? a.code + " " : ""}${a.name}`, a, "account");
|
||||
if (g.name) pushRow(` Total for ${g.name}`, g, "subtotal");
|
||||
}
|
||||
pushRow(totalLabel, total, "total");
|
||||
};
|
||||
emitSection("Income", model.incomeGroups, "Total Income", model.incTot);
|
||||
emitSection("Expense", model.expenseGroups, "Total Expense", model.expTot);
|
||||
pushRow("Net Income", model.net, "total");
|
||||
|
||||
const colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 150 } };
|
||||
const colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 160 } };
|
||||
for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" };
|
||||
autoTable(doc, {
|
||||
startY, head, body,
|
||||
@@ -797,7 +825,7 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
didParseCell: (data: any) => {
|
||||
if (data.section !== "body") return;
|
||||
if (boldRows.has(data.row.index)) data.cell.styles.fontStyle = "bold";
|
||||
if (sectionRows.has(data.row.index)) { data.cell.styles.fontStyle = "bold"; data.cell.styles.fillColor = [241, 245, 249]; }
|
||||
if (fillRows.has(data.row.index)) data.cell.styles.fillColor = [241, 245, 249];
|
||||
},
|
||||
});
|
||||
drawBrandedFooter(doc);
|
||||
@@ -808,15 +836,20 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
||||
const lines = [["Account", ...periods.map((p) => p.label), "Total"].map(esc).join(",")];
|
||||
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
|
||||
const row = (label: string, by: Map<string, number> | null, total: number | null) =>
|
||||
lines.push([esc(label), ...periods.map((p) => (by ? f(by.get(p.key) ?? 0) : "")), total == null ? "" : f(total)].join(","));
|
||||
row("Income", null, null);
|
||||
for (const a of model.income) row(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total);
|
||||
row("Total Income", model.incTot.by, model.incTot.total);
|
||||
row("Expense", null, null);
|
||||
for (const a of model.expense) row(`${a.code ? a.code + " " : ""}${a.name}`, a.by, a.total);
|
||||
row("Total Expense", model.expTot.by, model.expTot.total);
|
||||
row("Net Income", model.net.by, model.net.total);
|
||||
const row = (label: string, t: ISTotal | null) =>
|
||||
lines.push([esc(label), ...periods.map((p) => (t ? f(t.by.get(p.key) ?? 0) : "")), t == null ? "" : f(t.total)].join(","));
|
||||
const section = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
|
||||
row(title, null);
|
||||
for (const g of groups) {
|
||||
if (g.name) row(g.name, null);
|
||||
for (const a of g.accts) row(`${a.code ? a.code + " " : ""}${a.name}`, a);
|
||||
if (g.name) row(`Total for ${g.name}`, g);
|
||||
}
|
||||
row(totalLabel, total);
|
||||
};
|
||||
section("Income", model.incomeGroups, "Total Income", model.incTot);
|
||||
section("Expense", model.expenseGroups, "Total Expense", model.expTot);
|
||||
row("Net Income", model.net);
|
||||
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
@@ -868,8 +901,8 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ISSection title="Income" accts={model.income} periods={periods} totalLabel="Total Income" total={model.incTot} currency={currency} numCell={numCell} />
|
||||
<ISSection title="Expense" accts={model.expense} periods={periods} totalLabel="Total Expense" total={model.expTot} currency={currency} numCell={numCell} />
|
||||
<ISSection title="Income" groups={model.incomeGroups} totalLabel="Total Income" total={model.incTot} periods={periods} currency={currency} numCell={numCell} />
|
||||
<ISSection title="Expense" groups={model.expenseGroups} totalLabel="Total Expense" total={model.expTot} periods={periods} currency={currency} numCell={numCell} />
|
||||
<tr className="border-t-2 border-primary font-bold">
|
||||
<td className="px-3 py-2.5">Net Income</td>
|
||||
{periods.map((p) => <td key={p.key} className={numCell}>{money(model.net.by.get(p.key) ?? 0, currency)}</td>)}
|
||||
@@ -884,25 +917,41 @@ function IncomeStatementReport({ companyId, companyName, from, to, currency, log
|
||||
);
|
||||
}
|
||||
|
||||
function ISSection({ title, accts, periods, totalLabel, total, currency, numCell }: {
|
||||
title: string; accts: ISAcct[]; periods: { key: string; label: string }[];
|
||||
totalLabel: string; total: { by: Map<string, number>; total: number }; currency: string; numCell: string;
|
||||
function ISSection({ title, groups, periods, totalLabel, total, currency, numCell }: {
|
||||
title: string; groups: ISGroup[]; periods: { key: string; label: string }[];
|
||||
totalLabel: string; total: ISTotal; currency: string; numCell: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className="bg-muted/40 font-semibold">
|
||||
<td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
|
||||
</tr>
|
||||
{accts.map((a) => (
|
||||
<tr key={a.id} className="border-b hover:bg-muted/20">
|
||||
<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>
|
||||
{periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
|
||||
<td className={numCell + " font-medium"}>{isNum(a.total)}</td>
|
||||
</tr>
|
||||
{groups.map((g, gi) => (
|
||||
<Fragment key={g.name ?? `__none-${gi}`}>
|
||||
{g.name && (
|
||||
<tr className="font-semibold text-[13px]">
|
||||
<td className="px-3 pl-6 py-1.5" colSpan={periods.length + 2}>{g.name}</td>
|
||||
</tr>
|
||||
)}
|
||||
{g.accts.map((a) => (
|
||||
<tr key={a.id} className="border-b hover:bg-muted/20">
|
||||
<td className={`px-3 py-1.5 ${g.name ? "pl-10" : "pl-6"}`}>
|
||||
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
|
||||
</td>
|
||||
{periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
|
||||
<td className={numCell + " font-medium"}>{isNum(a.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{g.name && (
|
||||
<tr className="border-b font-medium text-muted-foreground">
|
||||
<td className="px-3 pl-6 py-1.5">Total for {g.name}</td>
|
||||
{periods.map((p) => <td key={p.key} className={numCell}>{money(g.by.get(p.key) ?? 0, currency)}</td>)}
|
||||
<td className={numCell}>{money(g.total, currency)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
<tr className="border-b font-semibold">
|
||||
<tr className="border-y-2 border-primary/40 font-semibold">
|
||||
<td className="px-3 py-1.5">{totalLabel}</td>
|
||||
{periods.map((p) => <td key={p.key} className={numCell}>{money(total.by.get(p.key) ?? 0, currency)}</td>)}
|
||||
<td className={numCell}>{money(total.total, currency)}</td>
|
||||
|
||||
Reference in New Issue
Block a user