diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 1c8b7b0..66cfe8b 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -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" && ( )} + {active === "income-statement" && ( + + )} {active === "trial-balance" && ( )} @@ -600,7 +604,7 @@ export default function AccountingReportsPage({ association }: { association?: { No data for this report in the selected range. ) )} - {!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" && ( {!data ? ( Loading… @@ -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; 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("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(); + 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(); 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(); + const sectionRows = new Set(); + const pushRow = (label: string, by: Map | 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 = { 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 | 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 ( + + + + + By + setGran(v as ISGran)}> + + + Month + Quarter + Year + + + + {periods.length} period{periods.length !== 1 ? "s" : ""} · Accrual basis + {hasRows && ( + + CSV + PDF + + )} + + + + {isLoading ? ( + Loading… + ) : !hasRows ? ( + No income or expense activity in this range. + ) : ( + + + + + + Account + {periods.map((p) => {p.label})} + Total + + + + + + + Net Income + {periods.map((p) => {money(model.net.by.get(p.key) ?? 0, currency)})} + {money(model.net.total, currency)} + + + + + + )} + + ); +} + +function ISSection({ title, accts, periods, totalLabel, total, currency, numCell }: { + title: string; accts: ISAcct[]; periods: { key: string; label: string }[]; + totalLabel: string; total: { by: Map; total: number }; currency: string; numCell: string; +}) { + return ( + <> + + {title} + + {accts.map((a) => ( + + + {a.code && {a.code}}{a.name} + + {periods.map((p) => {isNum(a.by.get(p.key) ?? 0)})} + {isNum(a.total)} + + ))} + + {totalLabel} + {periods.map((p) => {money(total.by.get(p.key) ?? 0, currency)})} + {money(total.total, currency)} + + > + ); +} + function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: { report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string; onDrill?: (accountId: string, label: string) => void;