// BalancePage — overview of net worth at `/balance`. // // Issue #141 (Bilan #3). Composes: // - BalanceOverviewCard (latest total + Δ% + staleness warning + new-snapshot CTA) // - Period selector (3M / 6M / 1A / 3A / Tout) // - Chart-mode toggle (Line / Stacked-by-category) // - BalanceEvolutionChart // - BalanceAccountsTable (one row per active account with latest value + Δ%) // // All data flows through `useBalanceOverview` (scoped useReducer). Returns // (Modified Dietz) are deferred to Issue #142 — the accounts table reserves // columns with a TODO comment. import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Wallet } from "lucide-react"; import { useBalanceOverview, type BalancePeriod, type BalanceChartMode, type BalanceGroupAxis, } from "../hooks/useBalanceOverview"; import { BALANCE_VEHICLE_TYPES } from "../shared/types"; import { VEHICLE_NONE_BUCKET } from "../services/balance.service"; import { archiveBalanceAccount, listAccountTransfers, type AccountLatestSnapshot, } from "../services/balance.service"; import { getAllCategories } from "../services/transactionService"; import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types"; import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import LinkTransfersModal from "../components/balance/LinkTransfersModal"; import StarterAccountsModal from "../components/balance/StarterAccountsModal"; import { getPreference, setPreference } from "../services/userPreferenceService"; import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel"; const STARTER_PREF_KEY = "balance_starter_proposed"; const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; export default function BalancePage() { const { t } = useTranslation(); const { state, setPeriod, setChartMode, setGroupAxis, reload } = useBalanceOverview(); // Issue #142 — link-transfers modal state. Categories list is loaded once // on mount (used by the modal's filter dropdown). const [linkTarget, setLinkTarget] = useState( null ); const [categories, setCategories] = useState([]); const [transfersByAccount, setTransfersByAccount] = useState< Map >(new Map()); useEffect(() => { void getAllCategories().then(setCategories).catch(() => setCategories([])); }, []); // Issue #179 — one-shot starter-accounts modal for existing profiles. The // pref `balance_starter_proposed` is written once (confirmed or dismissed), // so the modal never re-appears. New profiles get both the 4 starters AND // the pref pre-seeded via consolidated_schema.sql, so they never hit this // branch at all (S1 fix from #187). const [showStarterModal, setShowStarterModal] = useState(false); useEffect(() => { let cancelled = false; void (async () => { try { const existing = await getPreference(STARTER_PREF_KEY); if (!cancelled && existing == null) { setShowStarterModal(true); } } catch { // Pref read failure: leave modal hidden — privacy-first default. } })(); return () => { cancelled = true; }; }, []); const handleStarterModalClose = async (acceptedIds: number[]) => { setShowStarterModal(false); try { await setPreference( STARTER_PREF_KEY, JSON.stringify({ shown_at: new Date().toISOString(), accepted: acceptedIds, }) ); } catch { // Best-effort: a write failure here would cause the modal to re-show // on next visit, which is acceptable (data still consistent). } if (acceptedIds.length > 0) { await reload(); } }; // Refresh per-account transfer lists used by the chart markers. Keyed by // account_id → [transfers]. Used by `BalanceEvolutionChart` to plot // ReferenceLine markers (green for in, red for out). useEffect(() => { let cancelled = false; async function run() { const map = new Map(); await Promise.all( state.accountsLatest.map(async (acc) => { try { const list = await listAccountTransfers(acc.account_id); map.set(acc.account_id, list); } catch { map.set(acc.account_id, []); } }) ); if (!cancelled) setTransfersByAccount(map); } void run(); return () => { cancelled = true; }; }, [state.accountsLatest]); const allTransferMarkers = useMemo(() => { const flat: BalanceAccountTransferWithTransaction[] = []; for (const list of transfersByAccount.values()) flat.push(...list); return flat; }, [transfersByAccount]); // Earliest snapshot date in the dataset, used to anchor the "depuis // création" Modified Dietz horizon in the accounts table. const earliestSnapshotDate = useMemo(() => { if (state.evolutionTotals.length === 0) return null; return state.evolutionTotals[0].snapshot_date; }, [state.evolutionTotals]); // Build a category_key → translated label map from the accounts payload — // the byCategory series is keyed by `key`, not by id, and the same // taxonomy is already loaded with `accountsLatest` joins. const categoryLabels = useMemo(() => { const m: Record = {}; for (const a of state.accountsLatest) { if (!m[a.category_key]) { m[a.category_key] = renderCategoryLabelFromAccount(a, t); } } return m; }, [state.accountsLatest, t]); // Map vehicle_key → translated label for the "par enveloppe" stacked axis. // Reuses the #203 account-form labels (`balance.account.form.vehicleType.*`) // so the legend never duplicates strings; the NULL-envelope bucket uses the // dedicated `balance.vehicle.none` key. const vehicleLabels = useMemo(() => { const m: Record = { [VEHICLE_NONE_BUCKET]: t("balance.vehicle.none"), }; for (const v of BALANCE_VEHICLE_TYPES) { m[v] = t(`balance.account.form.vehicleType.${v}`); } return m; }, [t]); const handleArchiveAccount = async (accountId: number) => { try { await archiveBalanceAccount(accountId); await reload(); } catch { // Reload swallows; the row simply stays. UX feedback can be added later. } }; return (

{t("balance.overview.title")}

{state.error && (
{state.error}
)} {/* Issue #178 — empty-state guard. We probe accountsLatest for ANY snapshot date so the guard is independent of the active period filter (state.period). When empty, we render only the onboarding card — period selector, chart and accounts table would all show empty states stacked under it (S2 from #187). */} {(() => { const accountsCount = state.accountsLatest.length; const hasAnySnapshot = state.accountsLatest.some( (a) => a.latest_snapshot_date != null ); const isEmpty = accountsCount === 0 || !hasAnySnapshot; if (isEmpty) { return (
); } return (
{/* Period selector */}
{PERIOD_OPTIONS.map((p) => ( ))}
{/* Stacked-mode group-axis sub-toggle (asset class / envelope). Only meaningful while the stacked mode is active. */} {state.chartMode === "stacked" && (
{(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => ( ))}
)} {/* Chart mode toggle */}
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => ( ))}

{t("balance.overview.accountsTitle")}

handleArchiveAccount(acc.account_id)} onLinkTransfers={(acc) => setLinkTarget(acc)} />
); })()} { void handleStarterModalClose(ids); }} /> {linkTarget && ( setLinkTarget(null)} onLinked={() => { void reload(); }} /> )}
); }