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>
128 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|