Addresses /pr-review REQUEST_CHANGES on #255. - useCompare: the "skip first sync" boolean was not StrictMode-safe — the dev double-invoke of effects flipped the flag on setup #1, so setup #2 re-synced the reference month to the civil-year December, re-introducing the very bug Changement 2 fixes (dev only; prod has no double-invoke). Replace it with a value-change guard: a ref seeded with the initial `to` plus a pure syncReferenceOnPeriodChange() that only dispatches when `to` actually changes. Idempotent across the double-invoke, and now unit-tested (5 cases) since the decision is a pure function (the project has no renderHook harness). - Remove the now-orphaned reports.compare.totalRow i18n key (both locales) — the flat grand total it labelled was replaced by the result lines. - ComparePeriodTable: gate the "before transfers" line on results.hasTransfers (previously computed/tested but unused). - ComparePeriodChart: show the no-data empty state when the expense filter leaves nothing (a pure income/transfer period) instead of bare axes. Build + 690 vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
398 lines
17 KiB
TypeScript
398 lines
17 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";
|
||
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<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);
|
||
}
|
||
|
||
// 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 (
|
||
<Fragment key={section.type}>
|
||
<tr className="bg-[var(--muted)]">
|
||
<td
|
||
colSpan={COL_COUNT}
|
||
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: deltaColor(row.deltaAbs, higherIsBetter) }}
|
||
>
|
||
{formatSignedCurrency(row.deltaAbs, lang)}
|
||
</td>
|
||
<td
|
||
className="text-right px-3 py-1.5 tabular-nums"
|
||
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
|
||
>
|
||
{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: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
|
||
>
|
||
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
|
||
</td>
|
||
<td
|
||
className="text-right px-3 py-1.5 tabular-nums"
|
||
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
|
||
>
|
||
{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: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
|
||
>
|
||
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
|
||
</td>
|
||
<td
|
||
className="text-right px-3 py-2.5 tabular-nums"
|
||
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
|
||
>
|
||
{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: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
|
||
>
|
||
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
|
||
</td>
|
||
<td
|
||
className="text-right px-3 py-2.5 tabular-nums"
|
||
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
|
||
>
|
||
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
|
||
</td>
|
||
</tr>
|
||
</Fragment>
|
||
);
|
||
};
|
||
|
||
// 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 (
|
||
<tr key={labelKey} className={rowClass}>
|
||
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
|
||
<td
|
||
className={`${cell} border-l border-[var(--border)]/50`}
|
||
style={{ color: deltaColor(tot.monthCurrent, true) }}
|
||
>
|
||
{formatCurrency(tot.monthCurrent, lang)}
|
||
</td>
|
||
<td className={cell} style={{ color: deltaColor(tot.monthPrevious, true) }}>
|
||
{formatCurrency(tot.monthPrevious, lang)}
|
||
</td>
|
||
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
|
||
{formatSignedCurrency(tot.monthDelta, lang)}
|
||
</td>
|
||
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
|
||
{formatPct(pct(tot.monthDelta, tot.monthPrevious), lang)}
|
||
</td>
|
||
<td
|
||
className={`${cell} border-l border-[var(--border)]/50`}
|
||
style={{ color: deltaColor(tot.ytdCurrent, true) }}
|
||
>
|
||
{formatCurrency(tot.ytdCurrent, lang)}
|
||
</td>
|
||
<td className={cell} style={{ color: deltaColor(tot.ytdPrevious, true) }}>
|
||
{formatCurrency(tot.ytdPrevious, lang)}
|
||
</td>
|
||
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
|
||
{formatSignedCurrency(tot.ytdDelta, lang)}
|
||
</td>
|
||
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
|
||
{formatPct(pct(tot.ytdDelta, tot.ytdPrevious), lang)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
};
|
||
|
||
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={COL_COUNT} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
||
{t("reports.empty.noData")}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
<>
|
||
{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)}
|
||
</>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|