Simpl-Resultat/src/pages/ReportsCartesPage.tsx
le king fu 3be05db41a
All checks were successful
PR Check / rust (push) Successful in 21m48s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m16s
feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102)
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>
2026-04-19 09:49:21 -04:00

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>
);
}