mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting reports: add multi-period Income Statement
New "Income Statement" report with a By Month/Quarter/Year selector that lays out income/expense accounts in period columns plus a Total column, with Total Income / Total Expense / Net Income rows. Built from the GL over the selected range; includes branded PDF and CSV export. Accounts are listed flat under Income/Expense for now (category subgroups to follow once accounts are categorized). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo
|
||||
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
|
||||
|
||||
type ReportId =
|
||||
| "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
||||
| "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
||||
| "trial-balance" | "general-ledger"
|
||||
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
|
||||
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation"
|
||||
@@ -48,6 +48,7 @@ const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of
|
||||
const GROUPS = [
|
||||
{ name: "Business Overview", reports: [
|
||||
{ id: "pnl" as ReportId, name: "Profit & Loss" },
|
||||
{ id: "income-statement" as ReportId, name: "Income Statement" },
|
||||
{ id: "balance-sheet" as ReportId, name: "Balance Sheet" },
|
||||
{ id: "cash-flow" as ReportId, name: "Cash Flow Statement" },
|
||||
{ id: "movement-of-equity" as ReportId, name: "Movement of Equity" },
|
||||
@@ -395,7 +396,7 @@ export default function AccountingReportsPage({ association }: { association?: {
|
||||
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
|
||||
|
||||
// Reports whose export is handled internally (own PDF/CSV buttons inside the component)
|
||||
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
|
||||
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals" || active === "income-statement";
|
||||
const anyExportable = !!(structured || flat || exportFlat);
|
||||
|
||||
const doExportPDF = async () => {
|
||||
@@ -575,6 +576,9 @@ export default function AccountingReportsPage({ association }: { association?: {
|
||||
{active === "budget-vs-actuals" && (
|
||||
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} logoUrl={logoUrl} />
|
||||
)}
|
||||
{active === "income-statement" && (
|
||||
<IncomeStatementReport companyId={cid} companyName={associationName ?? "Company"} from={from} to={to} currency={cur} logoUrl={logoUrl} />
|
||||
)}
|
||||
{active === "trial-balance" && (
|
||||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||
)}
|
||||
@@ -600,7 +604,7 @@ export default function AccountingReportsPage({ association }: { association?: {
|
||||
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
|
||||
)
|
||||
)}
|
||||
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && (
|
||||
{!isFinancial && active !== "budget-vs-actuals" && active !== "income-statement" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && (
|
||||
<ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||
{!data ? (
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
@@ -653,6 +657,260 @@ function Toggle({ id, checked, onChange, label, disabled }: { id: string; checke
|
||||
);
|
||||
}
|
||||
|
||||
// ── Income Statement (multi-period: by month / quarter / year) ─────────────────
|
||||
type ISGran = "month" | "quarter" | "year";
|
||||
const IS_TEAL: [number, number, number] = [0, 137, 123];
|
||||
|
||||
function isPad2(n: number) { return String(n).padStart(2, "0"); }
|
||||
|
||||
/** Period columns spanning [from, to] at the chosen granularity. */
|
||||
function isBuildPeriods(from: string, to: string, gran: ISGran): { key: string; label: string }[] {
|
||||
const out: { key: string; label: string }[] = [];
|
||||
const fy = Number(from.slice(0, 4)), fm = Number(from.slice(5, 7));
|
||||
const ty = Number(to.slice(0, 4)), tm = Number(to.slice(5, 7));
|
||||
if (gran === "month") {
|
||||
let cy = fy, cm = fm;
|
||||
while (cy < ty || (cy === ty && cm <= tm)) {
|
||||
out.push({ key: `${cy}-${isPad2(cm)}`, label: `${isPad2(cm)}-${cy}` });
|
||||
cm++; if (cm > 12) { cm = 1; cy++; }
|
||||
}
|
||||
} else if (gran === "quarter") {
|
||||
let cy = fy, cq = Math.floor((fm - 1) / 3) + 1;
|
||||
const tq = Math.floor((tm - 1) / 3) + 1;
|
||||
while (cy < ty || (cy === ty && cq <= tq)) {
|
||||
out.push({ key: `${cy}-Q${cq}`, label: `Q${cq} ${cy}` });
|
||||
cq++; if (cq > 4) { cq = 1; cy++; }
|
||||
}
|
||||
} else {
|
||||
for (let cy = fy; cy <= ty; cy++) out.push({ key: `${cy}`, label: `${cy}` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Period key a given YYYY-MM-DD date falls into, for the chosen granularity. */
|
||||
function isPeriodKey(date: string, gran: ISGran): string {
|
||||
if (gran === "month") return date.slice(0, 7);
|
||||
if (gran === "year") return date.slice(0, 4);
|
||||
const q = Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1;
|
||||
return `${date.slice(0, 4)}-Q${q}`;
|
||||
}
|
||||
|
||||
/** Detail-cell number: 2dp, thousands-separated, parens for negatives, blank for zero. */
|
||||
function isNum(n: number): string {
|
||||
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
|
||||
if (v === 0) return "";
|
||||
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return v < 0 ? `(${abs})` : abs;
|
||||
}
|
||||
|
||||
type ISAcct = { id: string; code: string | null; name: string; type: string; by: Map<string, number>; total: number };
|
||||
|
||||
function IncomeStatementReport({ companyId, companyName, from, to, currency, logoUrl }: {
|
||||
companyId: string; companyName: string; from: string; to: string; currency: string; logoUrl?: string | null;
|
||||
}) {
|
||||
const [gran, setGran] = useState<ISGran>("month");
|
||||
|
||||
const { data: glLines = [], isLoading } = useQuery({
|
||||
queryKey: ["income-statement-gl", companyId, from, to],
|
||||
enabled: !!companyId,
|
||||
queryFn: () => fetchAllGLLines(
|
||||
companyId, to,
|
||||
"id,debit,credit,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)",
|
||||
from,
|
||||
),
|
||||
});
|
||||
|
||||
const periods = useMemo(() => isBuildPeriods(from, to, gran), [from, to, gran]);
|
||||
|
||||
const model = useMemo(() => {
|
||||
const accts = new Map<string, ISAcct>();
|
||||
for (const l of glLines as any[]) {
|
||||
const a = l.accounts; if (!a) continue;
|
||||
const type = a.type as string; if (type !== "income" && type !== "expense") continue;
|
||||
const date: string = l.journal_entries?.date ?? ""; if (!date) continue;
|
||||
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); }
|
||||
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 = {
|
||||
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 };
|
||||
}, [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 exportPdf = async () => {
|
||||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||||
const logo = await loadBrandedLogo(logoUrl);
|
||||
const startY = drawBrandedHeader(doc, {
|
||||
logo, title: "Income Statement", subtitle,
|
||||
metaLines: [{ label: "Properties:", value: companyName }],
|
||||
});
|
||||
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 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)),
|
||||
];
|
||||
if (kind === "total") boldRows.add(body.length);
|
||||
if (kind === "section") sectionRows.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 colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 150 } };
|
||||
for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" };
|
||||
autoTable(doc, {
|
||||
startY, head, body,
|
||||
styles: { fontSize: 7, cellPadding: 3, overflow: "linebreak" },
|
||||
headStyles: { fillColor: IS_TEAL, textColor: 255, halign: "right", fontSize: 7 },
|
||||
columnStyles: colStyles,
|
||||
margin: { left: 40, right: 40 },
|
||||
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]; }
|
||||
},
|
||||
});
|
||||
drawBrandedFooter(doc);
|
||||
doc.save(`income-statement-${gran}-${from}-to-${to}.pdf`);
|
||||
};
|
||||
|
||||
const exportCsv = () => {
|
||||
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 blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `income-statement-${gran}-${from}-to-${to}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
|
||||
const numCell = "px-3 py-1.5 text-right tabular-nums whitespace-nowrap";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">By</Label>
|
||||
<Select value={gran} onValueChange={(v) => setGran(v as ISGran)}>
|
||||
<SelectTrigger className="w-36 mt-1 h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="month">Month</SelectItem>
|
||||
<SelectItem value="quarter">Quarter</SelectItem>
|
||||
<SelectItem value="year">Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground pb-1">{periods.length} period{periods.length !== 1 ? "s" : ""} · Accrual basis</div>
|
||||
{hasRows && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" onClick={exportCsv}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||||
<Button onClick={exportPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
|
||||
) : !hasRows ? (
|
||||
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No income or expense activity in this range.</CardContent></Card>
|
||||
) : (
|
||||
<ReportSheet title="Income Statement" subtitle={subtitle} companyName={companyName} logoUrl={logoUrl}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">Account</th>
|
||||
{periods.map((p) => <th key={p.key} className="px-3 py-2 text-right font-semibold whitespace-nowrap">{p.label}</th>)}
|
||||
<th className="px-3 py-2 text-right font-semibold">Total</th>
|
||||
</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} />
|
||||
<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>)}
|
||||
<td className={numCell}>{money(model.net.total, currency)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ReportSheet>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
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>
|
||||
))}
|
||||
<tr className="border-b 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>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: {
|
||||
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
|
||||
onDrill?: (accountId: string, label: string) => void;
|
||||
|
||||
Reference in New Issue
Block a user