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>
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
// 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>
|
||
);
|
||
}
|