import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUpDown } from "lucide-react"; import type { CategoryDelta } from "../../shared/types"; import { reorderRows } from "../../utils/reorderRows"; import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults"; export interface ComparePeriodTableProps { rows: CategoryDelta[]; /** Label for the "previous" monthly column (e.g. "March 2026" or "2025"). */ previousLabel: string; /** Label for the "current" monthly column (e.g. "April 2026" or "2026"). */ currentLabel: string; /** Optional label for the previous cumulative window (YTD). Falls back to previousLabel. */ cumulativePreviousLabel?: string; /** Optional label for the current cumulative window (YTD). Falls back to currentLabel. */ cumulativeCurrentLabel?: string; } function formatCurrency(amount: number, language: string): string { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(amount); } function formatSignedCurrency(amount: number, language: string): string { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, signDisplay: "always", }).format(amount); } function formatPct(pctValue: number | null, language: string): string { if (pctValue === null) return "—"; return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "percent", maximumFractionDigits: 1, signDisplay: "always", }).format(pctValue / 100); } /** * Delta colour, direction-aware (Issue #253). Expenses/transfers keep the * spending convention (increase → red, decrease → green); income and the * result lines invert it (higher is better → green). Also used to colour a * result *amount* by sign: a surplus is green, a deficit red. */ function deltaColor(value: number, higherIsBetter: boolean): string { if (value === 0) return ""; const good = higherIsBetter ? value > 0 : value < 0; return good ? "var(--positive, #10b981)" : "var(--negative, #ef4444)"; } const STORAGE_KEY = "compare-subtotals-position"; const COL_COUNT = 9; export default function ComparePeriodTable({ rows, previousLabel, currentLabel, cumulativePreviousLabel, cumulativeCurrentLabel, }: ComparePeriodTableProps) { const { t, i18n } = useTranslation(); const lang = i18n.language; const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY); return stored === null ? true : stored === "top"; }); const toggleSubtotals = () => { setSubtotalsOnTop((prev) => { const next = !prev; localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom"); return next; }); }; const monthPrevLabel = previousLabel; const monthCurrLabel = currentLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel; // Group rows into contiguous type sections (the service already type-sorts: // income → expense → transfer). const sectionLabels: Record = { expense: t("reports.compare.sections.expenses"), income: t("reports.compare.sections.income"), transfer: t("reports.compare.sections.transfers"), }; const sectionTotalKeys: Record = { expense: "reports.compare.totalExpenses", income: "reports.compare.totalIncome", transfer: "reports.compare.totalTransfers", }; const sections: { type: SectionType; rows: CategoryDelta[] }[] = []; let currentType: SectionType | null = null; for (const row of rows) { const type = (row.category_type ?? "expense") as SectionType; if (type !== currentType) { currentType = type; sections.push({ type, rows: [] }); } sections[sections.length - 1].rows.push(row); } // Income-statement result lines (Issue #253): revenues − expenses, then the // net after transfers. Replaces the old flat grand total, which is meaningless // once revenues and (ABS) expenses share one table. const results = computeResults(rows); const nonTransferSections = sections.filter((s) => s.type !== "transfer"); const transferSection = sections.find((s) => s.type === "transfer"); const renderSection = (section: { type: SectionType; rows: CategoryDelta[] }) => { // Income section: an increase is good (green); expenses/transfers keep the // spending convention (increase → red). const higherIsBetter = section.type === "income"; const sectionTotals = sumLeaves(section.rows); return ( {sectionLabels[section.type]} {reorderRows(section.rows, subtotalsOnTop).map((row) => { const isParent = row.is_parent ?? false; const depth = row.depth ?? 0; const isTopParent = isParent && depth === 0; const isIntermediateParent = isParent && depth >= 1; const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; return ( {row.categoryName} {/* Monthly block */} {formatCurrency(row.currentAmount, lang)} {formatCurrency(row.previousAmount, lang)} {formatSignedCurrency(row.deltaAbs, lang)} {formatPct(row.deltaPct, lang)} {/* Cumulative YTD block */} {formatCurrency(row.cumulativeCurrentAmount, lang)} {formatCurrency(row.cumulativePreviousAmount, lang)} {formatSignedCurrency(row.cumulativeDeltaAbs, lang)} {formatPct(row.cumulativeDeltaPct, lang)} ); })} {/* Section net total */} {t(sectionTotalKeys[section.type])} {formatCurrency(sectionTotals.monthCurrent, lang)} {formatCurrency(sectionTotals.monthPrevious, lang)} {formatSignedCurrency(sectionTotals.monthDelta, lang)} {formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)} {formatCurrency(sectionTotals.ytdCurrent, lang)} {formatCurrency(sectionTotals.ytdPrevious, lang)} {formatSignedCurrency(sectionTotals.ytdDelta, lang)} {formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)} ); }; // A result line (before-transfers subtotal or the net total). Higher is always // better here: amounts are coloured by sign (surplus green / deficit red) and // deltas by improvement. const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => { const rowBg = strong ? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]" : "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]"; const rowClass = strong ? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}` : `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`; const cell = "text-right px-3 py-3 tabular-nums"; return ( {t(labelKey)} {formatCurrency(tot.monthCurrent, lang)} {formatCurrency(tot.monthPrevious, lang)} {formatSignedCurrency(tot.monthDelta, lang)} {formatPct(pct(tot.monthDelta, tot.monthPrevious), lang)} {formatCurrency(tot.ytdCurrent, lang)} {formatCurrency(tot.ytdPrevious, lang)} {formatSignedCurrency(tot.ytdDelta, lang)} {formatPct(pct(tot.ytdDelta, tot.ytdPrevious), lang)} ); }; return (
{rows.length === 0 ? ( ) : ( <> {nonTransferSections.map(renderSection)} {/* Operating result (revenues − expenses), shown before the transfers section only when transfers exist — otherwise it equals the net total below and would just be noise. */} {results.hasTransfers && renderResultRow( "reports.compare.resultBeforeTransfers", results.resultBefore, false, )} {transferSection && renderSection(transferSection)} {/* Bottom line: result after netting transfers. */} {renderResultRow("reports.compare.resultNet", results.resultNet, true)} )}
{t("reports.highlights.category")} {t("reports.bva.monthly")} {t("reports.bva.ytd")}
{t("reports.compare.currentAmount")}
{monthCurrLabel}
{t("reports.compare.previousAmount")}
{monthPrevLabel}
{t("reports.bva.dollarVar")} {t("reports.bva.pctVar")}
{t("reports.compare.currentAmount")}
{ytdCurrLabel}
{t("reports.compare.previousAmount")}
{ytdPrevLabel}
{t("reports.bva.dollarVar")} {t("reports.bva.pctVar")}
{t("reports.empty.noData")}
); }