Simpl-Resultat/src/components/balance/BalanceOverviewCard.tsx
le king fu ffefa90fd0 feat(balance): add BalancePage with chart + accounts table
Three new components composed under a new BalancePage at /balance:

- BalanceOverviewCard — latest aggregate net worth, Δ% vs the
  previous chronological snapshot (rendered as "—" when only
  one snapshot exists), 60-day staleness warning, and a
  "+ Nouveau snapshot" CTA pointing at /balance/snapshot.

- BalanceEvolutionChart — Recharts-based line / stacked-area
  toggle. Line mode plots SUM(value) per snapshot_date with a
  single primary-coloured stroke. Stacked mode transposes the
  byCategory series into one Area per category_key with a
  fixed 10-color palette indexed deterministically. Tooltip
  formats CAD via Intl.NumberFormat.

- BalanceAccountsTable — one row per active account with name,
  category label, latest value, and Δ% over the active period
  (latest_value vs the period anchor). Returns columns
  (3M / 1Y / since-creation / unadjusted) reserved for #142
  with a TODO marker. Action menu includes a disabled "Detail"
  placeholder + functional "Archive" wired through reload().

BalancePage composes the three with an inline period selector
(3M / 6M / 1A / 3A / Tout) and chart-mode toggle, both styled
as segmented controls. State flows through useBalanceOverview.

Route /balance registered before /balance/accounts in App.tsx.

Refs: #141

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:07:04 -04:00

128 lines
4.4 KiB
TypeScript

// BalanceOverviewCard — top summary tile of /balance.
//
// Issue #141 (Bilan #3). Displays:
// - The latest aggregate snapshot total (sum across all accounts on the
// most recent snapshot date).
// - Δ% versus the previous chronological snapshot (null when only one
// snapshot exists; rendered as "—").
// - A staleness warning when the latest snapshot is older than 60 days.
// - "+ Nouveau snapshot" CTA → `/balance/snapshot`.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
import { Link } from "react-router-dom";
import type { SnapshotTotalPoint } from "../../services/balance.service";
const STALENESS_DAYS = 60;
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
}).format(value);
interface BalanceOverviewCardProps {
/** The full evolution series for the active period (latest at the end). */
totals: SnapshotTotalPoint[];
}
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
const { t, i18n } = useTranslation();
const summary = useMemo(() => {
if (totals.length === 0) {
return null;
}
const last = totals[totals.length - 1];
const prev = totals.length >= 2 ? totals[totals.length - 2] : null;
const deltaPct =
prev && prev.total !== 0
? ((last.total - prev.total) / Math.abs(prev.total)) * 100
: null;
const ageMs = Date.now() - new Date(last.snapshot_date).getTime();
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
return {
latest: last,
deltaPct,
isStale: ageDays > STALENESS_DAYS,
ageDays,
};
}, [totals]);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.overview.latestTotal")}
</p>
{summary ? (
<>
<p className="text-3xl font-bold mt-1">
{cadFormatter(summary.latest.total)}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("balance.overview.asOf", {
date: formatDate(summary.latest.snapshot_date),
})}
</p>
</>
) : (
<p className="text-sm text-[var(--muted-foreground)] mt-2">
{t("balance.overview.noSnapshots")}
</p>
)}
</div>
<div className="flex flex-col items-stretch sm:items-end gap-2">
{summary && summary.deltaPct !== null && (
<div
className={`inline-flex items-center gap-1 text-sm font-medium ${
summary.deltaPct >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}`}
>
{summary.deltaPct >= 0 ? (
<TrendingUp size={16} />
) : (
<TrendingDown size={16} />
)}
{summary.deltaPct >= 0 ? "+" : ""}
{summary.deltaPct.toFixed(2)}%
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
{t("balance.overview.vsPrevious")}
</span>
</div>
)}
<Link
to="/balance/snapshot"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
<Plus size={16} />
{t("balance.overview.newSnapshot")}
</Link>
</div>
</div>
{summary?.isStale && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/30 text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>
{t("balance.overview.staleWarning", { days: summary.ageDays })}
</span>
</div>
)}
</div>
);
}