Adds a segmented Monthly/YTD toggle next to the reference-month picker that flips the four KPI cards (income, expenses, net, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January to the reference month of the reference year; MoM delta compares it to Jan to (refMonth - 1) of the same year (null in January, since no prior YTD window exists); YoY delta compares it to Jan to refMonth of the previous year; savings rate is recomputed from YTD income and expenses, and stays null when YTD income is zero. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle (by design). The savings-rate tooltip is now dynamic and mirrors the active mode. The mode is persisted in localStorage under `reports-cartes-period-mode`. Also adds a dedicated Cartes section to `docs/guide-utilisateur.md` covering the four KPI formulas, the Monthly/YTD toggle and its effect on deltas, the sparkline, top movers, seasonality, budget adherence and the savings-rate edge case. Mirrored in the in-app `docs.reports` i18n tree (features/steps/ tips extended) for both FR and EN. No SQL migration: YTD sums are computed from the already-fetched `flowByMonth` map, so no extra round trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
// The Cartes report is intentionally a "month X vs X-1 vs X-12" snapshot, so
|
|
// only a reference-month picker is surfaced here — a generic date-range
|
|
// selector has no meaning on this sub-report (see issue #101).
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
|
import CartesPeriodModeToggle from "../components/reports/cards/CartesPeriodModeToggle";
|
|
import KpiCard from "../components/reports/cards/KpiCard";
|
|
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
|
import TopMoversList from "../components/reports/cards/TopMoversList";
|
|
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
|
|
import SeasonalityCard from "../components/reports/cards/SeasonalityCard";
|
|
import { useCartes } from "../hooks/useCartes";
|
|
|
|
export default function ReportsCartesPage() {
|
|
const { t } = useTranslation();
|
|
const { year, month, mode, snapshot, isLoading, error, setReferencePeriod, setMode } =
|
|
useCartes();
|
|
|
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
|
|
|
// The savings-rate tooltip copy depends on the active period mode so users
|
|
// always see the formula that matches the number currently on screen. The
|
|
// i18n key is a nested object (month / ytd) — suffixing keeps the two
|
|
// variants side by side in the locale files.
|
|
const savingsRateTooltip = t(
|
|
mode === "ytd"
|
|
? "reports.cartes.savingsRateTooltip.ytd"
|
|
: "reports.cartes.savingsRateTooltip.month",
|
|
);
|
|
|
|
return (
|
|
<div className={isLoading ? "opacity-60" : ""}>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Link
|
|
to={`/reports${preserveSearch}`}
|
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
|
aria-label={t("reports.hub.title")}
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</Link>
|
|
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-3 mb-6 flex-wrap">
|
|
<CartesPeriodModeToggle value={mode} onChange={setMode} />
|
|
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!snapshot ? (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
|
{t("reports.empty.noData")}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
<section
|
|
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"
|
|
aria-label={t("reports.cartes.kpiSectionAria")}
|
|
>
|
|
<KpiCard
|
|
id="income"
|
|
title={t("reports.cartes.income")}
|
|
kpi={snapshot.kpis.income}
|
|
format="currency"
|
|
deltaIsBadWhenUp={false}
|
|
/>
|
|
<KpiCard
|
|
id="expenses"
|
|
title={t("reports.cartes.expenses")}
|
|
kpi={snapshot.kpis.expenses}
|
|
format="currency"
|
|
deltaIsBadWhenUp={true}
|
|
/>
|
|
<KpiCard
|
|
id="net"
|
|
title={t("reports.cartes.net")}
|
|
kpi={snapshot.kpis.net}
|
|
format="currency"
|
|
deltaIsBadWhenUp={false}
|
|
/>
|
|
<KpiCard
|
|
id="savingsRate"
|
|
title={t("reports.cartes.savingsRate")}
|
|
kpi={snapshot.kpis.savingsRate}
|
|
format="percent"
|
|
deltaIsBadWhenUp={false}
|
|
tooltip={savingsRateTooltip}
|
|
/>
|
|
</section>
|
|
|
|
<IncomeExpenseOverlayChart flow={snapshot.flow12Months} />
|
|
|
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
<TopMoversList movers={snapshot.topMoversUp} direction="up" />
|
|
<TopMoversList movers={snapshot.topMoversDown} direction="down" />
|
|
</section>
|
|
|
|
<section className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
<BudgetAdherenceCard adherence={snapshot.budgetAdherence} />
|
|
<SeasonalityCard
|
|
seasonality={snapshot.seasonality}
|
|
referenceYear={year}
|
|
referenceMonth={month}
|
|
/>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|