diff --git a/src/components/balance/BalanceAccountsTable.tsx b/src/components/balance/BalanceAccountsTable.tsx index a0b86ed..c003337 100644 --- a/src/components/balance/BalanceAccountsTable.tsx +++ b/src/components/balance/BalanceAccountsTable.tsx @@ -1,22 +1,32 @@ // BalanceAccountsTable — one-row-per-active-account table on /balance. // -// Issue #141 (Bilan #3). Columns: -// - Account name + category label -// - Latest snapshot value (or "—" when no snapshot exists yet) -// - Δ% over the active period (latest value vs the period-anchor value; -// null when no anchor exists, rendered as "—"). -// - Actions menu (Detail no-op for now, Archive via service). +// 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: // -// Future return-metric columns (3M / 1A / since-creation / unadjusted) -// land in Issue #142. They have a TODO marker below. +// - 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 { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Archive, MoreVertical } from "lucide-react"; +import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react"; import type { AccountLatestSnapshot, AccountPeriodAnchor, } from "../../services/balance.service"; +import { computeAccountReturn } from "../../services/balance.service"; +import type { AccountReturn } from "../../shared/types"; const cadFormatter = (locale: string) => new Intl.NumberFormat(locale, { @@ -25,16 +35,55 @@ const cadFormatter = (locale: string) => 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>>; + export default function BalanceAccountsTable({ accounts, periodAnchor, onArchiveAccount, + onLinkTransfers, + sinceCreationDate, }: BalanceAccountsTableProps) { const { t, i18n } = useTranslation(); const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); @@ -48,6 +97,65 @@ export default function BalanceAccountsTable({ const [openMenuFor, setOpenMenuFor] = useState(null); + // Returns cache. Cleared whenever the account list changes (new accounts, + // archive, etc.). Loaded lazily after mount. + const [returns, setReturns] = useState({}); + 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(() => { + 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 (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]); + if (accounts.length === 0) { return (
@@ -56,8 +164,73 @@ export default function BalanceAccountsTable({ ); } + /** Format a return percentage with sign + colour-aware classname. */ + function renderReturnCell(r: AccountReturn | undefined) { + if (!r) { + return ; + } + if (r.return_pct === null) { + return ( + + + — + + ); + } + const pct = r.return_pct * 100; + return ( + + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + } + > + {pct >= 0 ? "+" : ""} + {pct.toFixed(2)}% + + {r.has_no_transfers_warning && ( + + )} + + ); + } + + /** + * 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 ; + } + if (r.value_start === 0) { + return ; + } + const simple = ((r.value_end - r.value_start) / r.value_start) * 100; + return ( + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + } + > + {simple >= 0 ? "+" : ""} + {simple.toFixed(2)}% + + ); + } + return ( -
+
@@ -73,7 +246,18 @@ export default function BalanceAccountsTable({ - {/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */} + + + + @@ -88,6 +272,7 @@ export default function BalanceAccountsTable({ Math.abs(anchor.anchor_value)) * 100 : null; + const accReturns = returns[acc.account_id] ?? {}; return ( + + + +
{t("balance.overview.periodDelta")} + {t("balance.accountsTable.return3m")} + + {t("balance.accountsTable.return1y")} + + {t("balance.accountsTable.sinceCreation")} + + {t("balance.accountsTable.unadjusted")} + {t("balance.account.fields.actions")}
+ {returnsLoading && !accReturns["3M"] + ? "…" + : renderReturnCell(accReturns["3M"])} + + {returnsLoading && !accReturns["1A"] + ? "…" + : renderReturnCell(accReturns["1A"])} + + {returnsLoading && !accReturns["since"] + ? "…" + : renderReturnCell(accReturns["since"])} + + {returnsLoading && !accReturns["1A"] + ? "…" + : renderUnadjustedCell(accReturns["1A"])} + {openMenuFor === acc.account_id && ( -
+
+ {onLinkTransfers && ( + + )} +
+ +
+ + + + +
+ +
+ {isLoading ? ( +
+ + {t("balance.transfers.modal.loading")} +
+ ) : error ? ( +
+ + {error} +
+ ) : rows.length === 0 ? ( +
+ {t("balance.transfers.modal.noTransactions")} +
+ ) : ( + + + + + + + + + + + + {rows.map((row) => { + const isSelected = selection.has(row.id); + const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount); + return ( + + + + + + + + ); + })} + +
+ {t("transactions.date")} + + {t("transactions.description")} + + {t("transactions.amount")} + + {t("balance.transfers.modal.direction")} +
+ toggleRow(row)} + aria-label={`select-${row.id}`} + /> + {row.date} + {row.description} + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`} + > + {fmt.format(row.amount)} + + {isSelected ? ( + + ) : ( + + {t(`balance.transfers.direction.${direction}`)} + + )} +
+ )} +
+ + {submitError && ( +
+ {submitError} +
+ )} + +
+
+ {t("balance.transfers.modal.summary", { + selected: selectedCount, + total: allFiltered, + })} +
+
+ + +
+
+
+ , + document.body + ); +} diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index 85b5772..41e2904 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -11,7 +11,7 @@ // (Modified Dietz) are deferred to Issue #142 — the accounts table reserves // columns with a TODO comment. -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Wallet } from "lucide-react"; import { @@ -19,10 +19,17 @@ import { type BalancePeriod, type BalanceChartMode, } from "../hooks/useBalanceOverview"; -import { archiveBalanceAccount } 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 BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; +import LinkTransfersModal from "../components/balance/LinkTransfersModal"; const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; @@ -30,6 +37,58 @@ export default function BalancePage() { const { t } = useTranslation(); const { state, setPeriod, setChartMode, 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([])); + }, []); + + // 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. @@ -123,6 +182,7 @@ export default function BalancePage() { totals={state.evolutionTotals} byCategory={state.evolutionByCategory} categoryLabels={categoryLabels} + transferMarkers={allTransferMarkers} />
@@ -132,10 +192,24 @@ export default function BalancePage() { handleArchiveAccount(acc.account_id)} + onLinkTransfers={(acc) => setLinkTarget(acc)} />
+ + {linkTarget && ( + setLinkTarget(null)} + onLinked={() => { + void reload(); + }} + /> + )} ); } diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index fd241f3..a178303 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -15,7 +15,6 @@ import { loadProfiles } from "./profileService"; import type { AccountReturn, BalanceAccount, - BalanceAccountTransfer, BalanceAccountTransferWithTransaction, BalanceAccountWithCategory, BalanceCategory,