New /reports/cartes page surfaces a dashboard-style snapshot of the reference month: - 4 KPI cards (income / expenses / net / savings rate) showing MoM and YoY deltas simultaneously, each with a 13-month sparkline highlighting the reference month - 12-month income vs expenses overlay chart (bars + net balance line) - Top 5 category increases + top 5 decreases MoM, clickable through to the category zoom report - Budget adherence card: on-target count + 3 worst overruns with progress bars - Seasonality card: reference month vs same calendar month averaged over the two previous years, with deviation indicator All data is fetched in a single getCartesSnapshot() service call that runs four queries concurrently (25-month flow, MoM category deltas, budget-vs-actual, seasonality). Missing months are filled with zeroes in the sparklines but preserved as null in the MoM/YoY deltas so the UI can distinguish "no data" from "zero spend". - Exported pure helpers: shiftMonth, defaultCartesReferencePeriod - 13 vitest cases covering zero data, MoM/YoY computation, January wrap-around, missing-month handling, division by zero for the savings rate, seasonality with and without history, top mover sign splitting and 5-cap Note: src/components/reports/CompareReferenceMonthPicker.tsx is a temporary duplicate — the canonical copy lives on the issue-96 branch (refactor: compare report). Once both branches merge the content is identical and git will dedupe. Keeping the local copy here means the Cartes branch builds cleanly on main without depending on #96. Closes #97 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
120 lines
4.1 KiB
TypeScript
120 lines
4.1 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
|
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,
|
|
snapshot,
|
|
isLoading,
|
|
error,
|
|
setReferencePeriod,
|
|
period,
|
|
setPeriod,
|
|
from,
|
|
to,
|
|
setCustomDates,
|
|
} = useCartes();
|
|
|
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
|
|
|
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-between gap-3 mb-6 flex-wrap">
|
|
<PeriodSelector
|
|
value={period}
|
|
onChange={setPeriod}
|
|
customDateFrom={from}
|
|
customDateTo={to}
|
|
onCustomDateChange={setCustomDates}
|
|
/>
|
|
<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}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|