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:
2026-06-10 21:04:49 -04:00
parent ca092f7b8b
commit cb8a29696f
+261 -3
View File
@@ -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;