Simpl-Resultat/src/components/reports/ComparePeriodTable.tsx
le king fu f3e8e94b16 feat(reports): hierarchical real-vs-real compare with subtotals
Render the real-vs-real comparable report as a parent/child category tree
with subtotal rows, mirroring the budget report — rows grouped into
expense/income/transfer sections with per-section and grand totals and a
subtotals-on-top/bottom toggle. The compare service now builds the tree on
top of the flat per-category deltas, so leaf-category figures are unchanged
and the #243 transfer netting is preserved (a balanced transfer group
subtotals to ~0, i.e. the group's net).

- reportService: buildCompareTree() synthesizes subtotal rows from the flat
  leaves + category metadata; getCompareMonthOverMonth/YoY return the tree
  (COMPARE_DELTA_SQL and rowsToDeltas untouched).
- CategoryDelta gains optional parent_id/is_parent/depth/category_type.
- ComparePeriodTable: sections, depth indentation, reorderRows toggle,
  section/grand net totals.
- Cartes top-movers and ComparePeriodChart filter to leaf rows only.
- reorderRows constraint loosened to the two fields it reads.
- i18n (FR+EN) section labels; CHANGELOG entries.

Resolves #247

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:32:38 -04:00

396 lines
18 KiB
TypeScript

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";
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(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
function variationColor(value: number): string {
// Compare report deals with expenses (abs values): spending more is negative
// for the user, spending less is positive. Mirror that colour convention
// consistently so the eye parses the delta sign at a glance.
if (value > 0) return "var(--negative, #ef4444)";
if (value < 0) return "var(--positive, #10b981)";
return "";
}
const STORAGE_KEY = "compare-subtotals-position";
type SectionType = "expense" | "income" | "transfer";
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
);
}
function pct(delta: number, previous: number): number | null {
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
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).
const sectionLabels: Record<SectionType, string> = {
expense: t("reports.compare.sections.expenses"),
income: t("reports.compare.sections.income"),
transfer: t("reports.compare.sections.transfers"),
};
const sectionTotalKeys: Record<SectionType, string> = {
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);
}
// Grand totals across every leaf.
const totals = sumLeaves(rows);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th
rowSpan={2}
className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]"
>
{t("reports.highlights.category")}
</th>
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.monthly")}
</th>
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={9} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
<>
{sections.map((section) => {
const sectionTotals = sumLeaves(section.rows);
return (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td
colSpan={9}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{sectionLabels[section.type]}
</td>
</tr>
{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 (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
>
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, lang)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, lang)}
</td>
</tr>
);
})}
{/* Section net total */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
})}
{/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(totals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(pct(totals.monthDelta, totals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(pct(totals.ytdDelta, totals.ytdPrevious), lang)}
</td>
</tr>
</>
)}
</tbody>
</table>
</div>
</div>
);
}