Simpl-Resultat/src/components/balance/BalanceAccountsTable.tsx
le king fu 0104e9223a
All checks were successful
PR Check / rust (pull_request) Successful in 22m13s
PR Check / frontend (pull_request) Successful in 2m21s
feat(balance): chart vehicle/class toggle + collapsible returns (#204)
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>
2026-06-01 21:05:00 -04:00

468 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// BalanceAccountsTable — one-row-per-active-account table on /balance.
//
// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
// the Modified Dietz `compute_account_return` Tauri command:
//
// - 3M (last 90 days)
// - 1A (last 365 days)
// - Depuis création (from earliest snapshot date to today)
// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution
// weighting — shown side-by-side as a sanity check / explanation)
//
// Returns load lazily on mount via `Promise.all` over (account × horizon),
// keyed by `account_id`. Each cell renders "—" while loading and shows the
// `is_partial` / `has_no_transfers_warning` badges via tooltip when set.
//
// Issue #142 also adds a "Lier transferts" item in the per-row actions menu
// that opens `LinkTransfersModal` (the modal handles its own state; this
// component just bubbles up the request via `onLinkTransfers`).
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Archive,
MoreVertical,
Link as LinkIcon,
AlertTriangle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import type {
AccountLatestSnapshot,
AccountPeriodAnchor,
} from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
import type { AccountReturn } from "../../shared/types";
import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel";
/**
* Preference key persisting whether the 4 Modified-Dietz return columns are
* expanded. Absent/anything-but-"1" → collapsed (the spec default). Stored via
* `userPreferenceService` so the choice survives across sessions (Issue #204).
*/
const SHOW_RETURNS_PREF_KEY = "balance_show_returns";
const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
});
/** Horizon definition: how many days back from today to start the period. */
type HorizonKey = "3M" | "1A" | "since";
interface HorizonRange {
key: HorizonKey;
/** ISO date for `period_start`. */
from: string;
/** ISO date for `period_end` (always today, computed in the local civil day). */
to: string;
}
function localISO(d: Date): string {
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
function isoDaysAgo(days: number, today: Date): string {
const d = new Date(today);
d.setDate(d.getDate() - days);
return localISO(d);
}
interface BalanceAccountsTableProps {
accounts: AccountLatestSnapshot[];
periodAnchor: AccountPeriodAnchor[];
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
/**
* Earliest snapshot date across the whole profile, used to anchor the
* "depuis création" horizon. Falls back to "1A" range if not provided
* (avoids triggering computation against the unix epoch).
*/
sinceCreationDate?: string | null;
}
/**
* Per-account, per-horizon return — shape used by the local cache state.
* Indexed `[accountId][horizonKey]`.
*/
type ReturnsByAccount = Record<number, Partial<Record<HorizonKey, AccountReturn>>>;
export default function BalanceAccountsTable({
accounts,
periodAnchor,
onArchiveAccount,
onLinkTransfers,
sinceCreationDate,
}: BalanceAccountsTableProps) {
const { t, i18n } = useTranslation();
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
/** account_id → period anchor (start-of-period value). */
const anchorMap = useMemo(() => {
const m = new Map<number, AccountPeriodAnchor>();
for (const a of periodAnchor) m.set(a.account_id, a);
return m;
}, [periodAnchor]);
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
// Progressive disclosure of the 4 return columns (Issue #204). Collapsed by
// default; the persisted choice is read once on mount. We start `false` so
// the columns never flash open before the preference resolves.
const [showReturns, setShowReturns] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const stored = await getPreference(SHOW_RETURNS_PREF_KEY);
if (!cancelled && stored === "1") setShowReturns(true);
} catch {
// Pref read failure: keep the collapsed default.
}
})();
return () => {
cancelled = true;
};
}, []);
const toggleReturns = () => {
setShowReturns((prev) => {
const next = !prev;
// Best-effort persist; a write failure just means the next session
// falls back to the collapsed default.
void setPreference(SHOW_RETURNS_PREF_KEY, next ? "1" : "0").catch(
() => {}
);
return next;
});
};
// Returns cache. Cleared whenever the account list changes (new accounts,
// archive, etc.). Loaded lazily after mount — only while the columns are
// shown, so a collapsed table never runs the Modified-Dietz computation.
const [returns, setReturns] = useState<ReturnsByAccount>({});
const [returnsLoading, setReturnsLoading] = useState(false);
// Horizon definitions — recomputed once per mount via today's local civil
// day. We don't memoize against `accounts` because the dates don't depend
// on the row list.
const horizons = useMemo<HorizonRange[]>(() => {
const today = new Date();
const todayISO = localISO(today);
const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today);
return [
{ key: "3M", from: isoDaysAgo(90, today), to: todayISO },
{ key: "1A", from: isoDaysAgo(365, today), to: todayISO },
{ key: "since", from: sinceFrom, to: todayISO },
];
}, [sinceCreationDate]);
useEffect(() => {
let cancelled = false;
async function loadReturns() {
if (!showReturns || accounts.length === 0) {
setReturns({});
return;
}
setReturnsLoading(true);
const next: ReturnsByAccount = {};
// Run sequentially per account to avoid SQLite contention; per-horizon
// we can parallelize because they hit the same table set.
await Promise.all(
accounts.map(async (acc) => {
next[acc.account_id] = {};
const tasks = horizons.map(async (h) => {
try {
const r = await computeAccountReturn(
acc.account_id,
h.from,
h.to
);
next[acc.account_id]![h.key] = r;
} catch {
// Per-cell failure: leave the slot undefined → renders "—".
}
});
await Promise.all(tasks);
})
);
if (!cancelled) {
setReturns(next);
setReturnsLoading(false);
}
}
void loadReturns();
return () => {
cancelled = true;
};
}, [accounts, horizons, showReturns]);
if (accounts.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
{t("balance.overview.noAccounts")}
</div>
);
}
/** Format a return percentage with sign + colour-aware classname. */
function renderReturnCell(r: AccountReturn | undefined) {
if (!r) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.return_pct === null) {
return (
<span
className="text-[var(--muted-foreground)] inline-flex items-center gap-1"
title={t("balance.returns.partialTooltip")}
>
<AlertTriangle size={12} />
</span>
);
}
const pct = r.return_pct * 100;
return (
<span className="inline-flex items-center gap-1">
<span
className={
pct >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%
</span>
{r.has_no_transfers_warning && (
<AlertTriangle
size={12}
className="text-amber-500"
aria-label={t("balance.returns.noTransfersWarning")}
/>
)}
</span>
);
}
/**
* Unadjusted (simple) return = `(value_end - value_start) / value_start`
* — same numbers Modified Dietz already returns when no flows exist, but
* this column shows the simple version for ALL accounts as a side-by-side
* sanity check. Computed from the same `AccountReturn` payload (uses the
* `value_start` / `value_end` fields filled by the Rust side).
*/
function renderUnadjustedCell(r: AccountReturn | undefined) {
if (!r || r.value_start === null || r.value_end === null) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.value_start === 0) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
const simple = ((r.value_end - r.value_start) / r.value_start) * 100;
return (
<span
className={
simple >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{simple >= 0 ? "+" : ""}
{simple.toFixed(2)}%
</span>
);
}
return (
<div className="space-y-2">
<div className="flex justify-end">
<button
type="button"
onClick={toggleReturns}
aria-expanded={showReturns}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
>
{showReturns ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
{t(
showReturns
? "balance.accountsTable.toggleReturns.hide"
: "balance.accountsTable.toggleReturns.show"
)}
</button>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30">
<tr>
<th className="text-left px-4 py-3 font-medium">
{t("balance.account.fields.name")}
</th>
<th className="text-left px-4 py-3 font-medium">
{t("balance.account.fields.category")}
</th>
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.latestValue")}
</th>
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")}
</th>
{showReturns && (
<>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
{t("balance.accountsTable.return1y")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
{t("balance.accountsTable.sinceCreation")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")}
</th>
</>
)}
<th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{accounts.map((acc) => {
const anchor = anchorMap.get(acc.account_id);
const deltaPct =
acc.latest_value !== null && anchor && anchor.anchor_value !== 0
? ((acc.latest_value - anchor.anchor_value) /
Math.abs(anchor.anchor_value)) *
100
: null;
const accReturns = returns[acc.account_id] ?? {};
return (
<tr
key={acc.account_id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-4 py-3 font-medium">
{acc.account_name}
{acc.symbol ? (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({acc.symbol})
</span>
) : null}
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)]">
{renderCategoryLabelFromAccount(acc, t)}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{deltaPct !== null ? (
<span
className={
deltaPct >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}
>
{deltaPct >= 0 ? "+" : ""}
{deltaPct.toFixed(2)}%
</span>
) : (
"—"
)}
</td>
{showReturns && (
<>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"]
? "…"
: renderReturnCell(accReturns["3M"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderReturnCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["since"]
? "…"
: renderReturnCell(accReturns["since"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderUnadjustedCell(accReturns["1A"])}
</td>
</>
)}
<td className="px-4 py-3 text-right relative">
<button
type="button"
onClick={() =>
setOpenMenuFor(
openMenuFor === acc.account_id ? null : acc.account_id
)
}
className="p-1 rounded hover:bg-[var(--muted)]/40"
aria-label={t("balance.account.fields.actions")}
>
<MoreVertical size={16} />
</button>
{openMenuFor === acc.account_id && (
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
<button
type="button"
disabled
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
title={t("balance.overview.detailComingSoon")}
>
{t("balance.overview.detailAction")}
</button>
{onLinkTransfers && (
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onLinkTransfers(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<LinkIcon size={14} />
{t("balance.transfers.linkAction")}
</button>
)}
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onArchiveAccount?.(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<Archive size={14} />
{t("balance.account.actions.archive")}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}