Simpl-Resultat/src/components/reports/cards/TopMoversList.tsx
le king fu 4c58b8bab8
All checks were successful
PR Check / rust (push) Successful in 23m21s
PR Check / frontend (push) Successful in 2m24s
PR Check / rust (pull_request) Successful in 23m12s
PR Check / frontend (pull_request) Successful in 2m20s
feat(reports/cartes): new KPI dashboard sub-report with sparklines, top movers, budget adherence and seasonality (#97)
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>
2026-04-15 19:44:58 -04:00

86 lines
3.1 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TrendingUp, TrendingDown } from "lucide-react";
import type { CartesTopMover } from "../../../shared/types";
export interface TopMoversListProps {
movers: CartesTopMover[];
direction: "up" | "down";
}
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 categoryHref(categoryId: number | null): string {
if (categoryId === null) return "/transactions";
const params = new URLSearchParams(window.location.search);
params.set("cat", String(categoryId));
return `/reports/category?${params.toString()}`;
}
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
const { t, i18n } = useTranslation();
const title =
direction === "up"
? t("reports.cartes.topMoversUp")
: t("reports.cartes.topMoversDown");
const Icon = direction === "up" ? TrendingUp : TrendingDown;
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon size={16} className={accentClass} />
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
</div>
{movers.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.empty.noData")}
</div>
) : (
<ul className="flex flex-col gap-1">
{movers.map((m) => (
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
<Link
to={categoryHref(m.categoryId)}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: m.categoryColor }}
/>
<span className="truncate text-sm text-[var(--foreground)]">
{m.categoryName}
</span>
</span>
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
{formatSignedCurrency(m.deltaAbs, i18n.language)}
<span className="text-[var(--muted-foreground)] ml-1">
{formatPct(m.deltaPct, i18n.language)}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}