Issue 3 of overnight-2026-06-01-bilan-axe-vehicule. Builds the tracking UI on top of the merged data layer (#202) and input UI (#203). - Service: getSnapshotTotalsByVehicleAndDate(range) mirrors the by-category aggregation with GROUP BY COALESCE(a.vehicle_type, 'none') so null-envelope accounts land in a single 'none' bucket (never a SQL NULL key). Add vehicle_type to getAccountsLatestSnapshot SELECT + type. - useBalanceOverview: new groupAxis ('class'|'vehicle') state ORTHOGONAL to chartMode; loads byVehicle alongside byCategory. Default groupAxis='class'. - BalanceEvolutionChart + BalancePage: stacked-mode sub-toggle 'Par classe d'actif' (default) / 'Par enveloppe'. Vehicle legend reuses the #203 vehicleType.* labels; the 'none' bucket uses balance.vehicle.none. - BalanceAccountsTable: 4 return columns collapsed by default with a toggle, persisted across sessions via userPreferenceService key balance_show_returns. - i18n FR/EN: balance.chart.axis.{byAssetClass,byVehicle}, balance.vehicle.none, balance.accountsTable.toggleReturns.{show,hide} (+ axisLegend aria label). Tests: npm run build green (0 type errors); vitest 3314 passed. Added 5 service tests for the 'none' bucket + mixed envelopes + date range. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
TypeScript
347 lines
13 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 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
|
|
);
|
|
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} />
|
|
|
|
<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}
|
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
|
onLinkTransfers={(acc) => setLinkTarget(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();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|