Simpl-Resultat/src/pages/BalancePage.tsx
le king fu 9608fd3618 feat(balance): detail-account wizard (toggle to detailed at pivot date) (#215)
Adds a light confirmation modal (DetailAccountWizard) that flips a simple
balance account to detailed entry mode: sets kind='detailed' and
detailed_since = today (local civil day, YYYY-MM-DD) via updateBalanceAccount.
Toggle-only — no title capture; per-security holdings are entered at the next
normal snapshot, where validateDetailedSnapshot requires them from the pivot on.

Entry point: a 'Détailler en titres' action in the per-row actions menu of
BalanceAccountsTable, shown only for kind==='simple' rows (replaces the disabled
'Détail / coming soon' placeholder). Past aggregated history stays frozen
read-only. The flip is one-way: the #212 service backstop rejects
detailed -> simple once holdings exist, and the UI exposes no inverse action.

Exports buildDetailToggleInput() as a pure helper for a focused unit test
(project has no jsdom harness). FR/EN i18n under balance.detailWizard.*; removed
the now-dead balance.overview.detailAction / detailComingSoon keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:55:58 -04:00

370 lines
14 KiB
TypeScript

// 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 DetailAccountWizard from "../components/balance/DetailAccountWizard";
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<AccountLatestSnapshot | null>(
null
);
// Issue #215 — "détailler en titres" wizard target (a simple account being
// flipped to detailed entry mode).
const [detailTarget, setDetailTarget] =
useState<AccountLatestSnapshot | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [transfersByAccount, setTransfersByAccount] = useState<
Map<number, BalanceAccountTransferWithTransaction[]>
>(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<number, BalanceAccountTransferWithTransaction[]>();
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<string, string> = {};
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<string, string> = {
[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 (
<div className={state.isLoading ? "opacity-60 pointer-events-none" : ""}>
<div className="flex items-center gap-3 mb-6">
<Wallet size={24} className="text-[var(--primary)]" />
<h1 className="text-2xl font-bold">{t("balance.overview.title")}</h1>
</div>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{state.error}
</div>
)}
{/* 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 (
<div className="space-y-6">
<BalanceOnboardingCard
accountsCount={accountsCount}
snapshotsCount={hasAnySnapshot ? 1 : 0}
/>
</div>
);
}
return (
<div className="space-y-6">
<BalanceOverviewCard
totals={state.evolutionTotals}
latentGainRollup={state.latentGainRollup}
/>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{/* Period selector */}
<div
role="group"
aria-label={t("balance.period.legend")}
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
>
{PERIOD_OPTIONS.map((p) => (
<button
key={p}
type="button"
onClick={() => setPeriod(p)}
className={`px-3 py-1.5 text-sm font-medium ${
state.period === p
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.period === p}
>
{t(`balance.period.${p}`)}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Stacked-mode group-axis sub-toggle (asset class / envelope).
Only meaningful while the stacked mode is active. */}
{state.chartMode === "stacked" && (
<div
role="group"
aria-label={t("balance.chart.axisLegend")}
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
>
{(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => (
<button
key={axis}
type="button"
onClick={() => setGroupAxis(axis)}
className={`px-3 py-1.5 text-sm font-medium ${
state.groupAxis === axis
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.groupAxis === axis}
>
{t(
axis === "class"
? "balance.chart.axis.byAssetClass"
: "balance.chart.axis.byVehicle"
)}
</button>
))}
</div>
)}
{/* Chart mode toggle */}
<div
role="group"
aria-label={t("balance.chart.modeLegend")}
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
>
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setChartMode(mode)}
className={`px-3 py-1.5 text-sm font-medium ${
state.chartMode === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.chartMode === mode}
>
{t(`balance.chart.mode.${mode}`)}
</button>
))}
</div>
</div>
</div>
<BalanceEvolutionChart
mode={state.chartMode}
groupAxis={state.groupAxis}
totals={state.evolutionTotals}
byCategory={state.evolutionByCategory}
byVehicle={state.evolutionByVehicle}
categoryLabels={categoryLabels}
vehicleLabels={vehicleLabels}
transferMarkers={allTransferMarkers}
/>
<div>
<h2 className="text-lg font-semibold mb-3">
{t("balance.overview.accountsTitle")}
</h2>
<BalanceAccountsTable
accounts={state.accountsLatest}
periodAnchor={state.accountsPeriodAnchor}
sinceCreationDate={earliestSnapshotDate}
latentGainByAccount={state.latentGainByAccount}
latentGainRollup={state.latentGainRollup}
vehicleLabels={vehicleLabels}
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
onLinkTransfers={(acc) => setLinkTarget(acc)}
onDetailAccount={(acc) => setDetailTarget(acc)}
/>
</div>
</div>
);
})()}
<StarterAccountsModal
isOpen={showStarterModal}
onClose={(ids) => {
void handleStarterModalClose(ids);
}}
/>
{linkTarget && (
<LinkTransfersModal
accountId={linkTarget.account_id}
accountName={linkTarget.account_name}
categories={categories}
onClose={() => setLinkTarget(null)}
onLinked={() => {
void reload();
}}
/>
)}
{detailTarget && (
<DetailAccountWizard
accountId={detailTarget.account_id}
accountName={detailTarget.account_name}
onClose={() => setDetailTarget(null)}
onDetailed={() => {
void reload();
}}
/>
)}
</div>
);
}