feat(balance): add LinkTransfersModal + return columns in accounts table
Issue #142 / Bilan #4 — UI for transfer linking + per-account returns. - New `LinkTransfersModal.tsx`: portal modal with date-range / category / free-text filters, multi-select with auto-proposed direction (`in` for negative bank amounts, `out` for positive — flippable per row). Submits via sequential `linkTransfer` calls; reports per-row failures inline (most common case: `transfer_already_linked` on a re-submit). - `BalanceAccountsTable.tsx`: 4 new columns rendered side-by-side — 3M / 1A / Since-inception (Modified Dietz via `compute_account_return`) + Unadjusted (`(V_end - V_start) / V_start`). Returns load lazily after mount via `Promise.all` over (account × horizon); per-cell failure leaves the slot at "—" without blocking the rest of the table. The actions menu gains a *Link transfers* item that bubbles the request up to the parent page. New props: `sinceCreationDate` (anchors the since-inception horizon) and `onLinkTransfers` (modal opener). - `BalancePage.tsx`: hosts the new modal, loads the categories list once on mount for the filter dropdown, fetches the union of `listAccountTransfers` per account so the chart can render markers, and threads the earliest snapshot date down to the table. Reload is triggered after the modal reports at least one successful link. - `balance.service.ts`: dropped the unused `BalanceAccountTransfer` import to satisfy `tsc --noUnusedLocals`. `npm run build` clean. `npm test` → 429 passed. Manual sanity check: the table renders "…" placeholders during the per-row return load, then resolves to either a percentage or a "—" with the partial tooltip when the underlying snapshot endpoint is missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dafdd4ce17
commit
a45e5c3cd0
4 changed files with 717 additions and 16 deletions
|
|
@ -1,22 +1,32 @@
|
||||||
// BalanceAccountsTable — one-row-per-active-account table on /balance.
|
// BalanceAccountsTable — one-row-per-active-account table on /balance.
|
||||||
//
|
//
|
||||||
// Issue #141 (Bilan #3). Columns:
|
// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
|
||||||
// - Account name + category label
|
// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
|
||||||
// - Latest snapshot value (or "—" when no snapshot exists yet)
|
// the Modified Dietz `compute_account_return` Tauri command:
|
||||||
// - Δ% 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).
|
|
||||||
//
|
//
|
||||||
// Future return-metric columns (3M / 1A / since-creation / unadjusted)
|
// - 3M (last 90 days)
|
||||||
// land in Issue #142. They have a TODO marker below.
|
// - 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 { useTranslation } from "react-i18next";
|
||||||
import { Archive, MoreVertical } from "lucide-react";
|
import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
AccountLatestSnapshot,
|
AccountLatestSnapshot,
|
||||||
AccountPeriodAnchor,
|
AccountPeriodAnchor,
|
||||||
} from "../../services/balance.service";
|
} from "../../services/balance.service";
|
||||||
|
import { computeAccountReturn } from "../../services/balance.service";
|
||||||
|
import type { AccountReturn } from "../../shared/types";
|
||||||
|
|
||||||
const cadFormatter = (locale: string) =>
|
const cadFormatter = (locale: string) =>
|
||||||
new Intl.NumberFormat(locale, {
|
new Intl.NumberFormat(locale, {
|
||||||
|
|
@ -25,16 +35,55 @@ const cadFormatter = (locale: string) =>
|
||||||
maximumFractionDigits: 2,
|
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 {
|
interface BalanceAccountsTableProps {
|
||||||
accounts: AccountLatestSnapshot[];
|
accounts: AccountLatestSnapshot[];
|
||||||
periodAnchor: AccountPeriodAnchor[];
|
periodAnchor: AccountPeriodAnchor[];
|
||||||
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
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({
|
export default function BalanceAccountsTable({
|
||||||
accounts,
|
accounts,
|
||||||
periodAnchor,
|
periodAnchor,
|
||||||
onArchiveAccount,
|
onArchiveAccount,
|
||||||
|
onLinkTransfers,
|
||||||
|
sinceCreationDate,
|
||||||
}: BalanceAccountsTableProps) {
|
}: BalanceAccountsTableProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
||||||
|
|
@ -48,6 +97,65 @@ export default function BalanceAccountsTable({
|
||||||
|
|
||||||
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Returns cache. Cleared whenever the account list changes (new accounts,
|
||||||
|
// archive, etc.). Loaded lazily after mount.
|
||||||
|
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 (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) {
|
if (accounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
|
@ -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 <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 (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-[var(--muted)]/30">
|
<thead className="bg-[var(--muted)]/30">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -73,7 +246,18 @@ export default function BalanceAccountsTable({
|
||||||
<th className="text-right px-4 py-3 font-medium">
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
{t("balance.overview.periodDelta")}
|
{t("balance.overview.periodDelta")}
|
||||||
</th>
|
</th>
|
||||||
{/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */}
|
<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">
|
<th className="text-right px-4 py-3 font-medium w-12">
|
||||||
{t("balance.account.fields.actions")}
|
{t("balance.account.fields.actions")}
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -88,6 +272,7 @@ export default function BalanceAccountsTable({
|
||||||
Math.abs(anchor.anchor_value)) *
|
Math.abs(anchor.anchor_value)) *
|
||||||
100
|
100
|
||||||
: null;
|
: null;
|
||||||
|
const accReturns = returns[acc.account_id] ?? {};
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={acc.account_id}
|
key={acc.account_id}
|
||||||
|
|
@ -123,6 +308,26 @@ export default function BalanceAccountsTable({
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<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">
|
<td className="px-4 py-3 text-right relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -137,7 +342,7 @@ export default function BalanceAccountsTable({
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16} />
|
||||||
</button>
|
</button>
|
||||||
{openMenuFor === acc.account_id && (
|
{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-[160px] text-left">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
|
|
@ -146,6 +351,19 @@ export default function BalanceAccountsTable({
|
||||||
>
|
>
|
||||||
{t("balance.overview.detailAction")}
|
{t("balance.overview.detailAction")}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
410
src/components/balance/LinkTransfersModal.tsx
Normal file
410
src/components/balance/LinkTransfersModal.tsx
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
// LinkTransfersModal — multi-select transactions and link them to a balance
|
||||||
|
// account in one shot. Issue #142 / Bilan #4.
|
||||||
|
//
|
||||||
|
// Filters available:
|
||||||
|
// - Period (from / to ISO dates) — default: last 90 days.
|
||||||
|
// - Category dropdown.
|
||||||
|
// - Free-text search on description.
|
||||||
|
//
|
||||||
|
// Each row shows: date, description, amount, suggested direction
|
||||||
|
// (auto-proposed via `suggestTransferDirection` from the signed amount,
|
||||||
|
// can be flipped per row), and a checkbox.
|
||||||
|
//
|
||||||
|
// On submit, calls `linkTransfer` for every selected row in sequence and
|
||||||
|
// reports any failures (most likely `transfer_already_linked` if the user
|
||||||
|
// double-clicked or another tab linked them already).
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X, Loader2, AlertCircle } from "lucide-react";
|
||||||
|
import { getTransactionPage } from "../../services/transactionService";
|
||||||
|
import {
|
||||||
|
linkTransfer,
|
||||||
|
suggestTransferDirection,
|
||||||
|
BalanceServiceError,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
TransactionRow,
|
||||||
|
BalanceTransferDirection,
|
||||||
|
} from "../../shared/types";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
function isoDaysAgo(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return localISO(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkTransfersModalProps {
|
||||||
|
/** Account that the selected transfers will be attached to. */
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
/** Full category list for the filter dropdown. */
|
||||||
|
categories: Category[];
|
||||||
|
/** Optional pre-fill date bounds (defaults to last 90 days). */
|
||||||
|
initialFrom?: string;
|
||||||
|
initialTo?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fired after at least one transfer was linked (parent typically reloads). */
|
||||||
|
onLinked?: (linkedCount: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LinkTransfersModal({
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
categories,
|
||||||
|
initialFrom,
|
||||||
|
initialTo,
|
||||||
|
onClose,
|
||||||
|
onLinked,
|
||||||
|
}: LinkTransfersModalProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const [from, setFrom] = useState(initialFrom ?? isoDaysAgo(90));
|
||||||
|
const [to, setTo] = useState(initialTo ?? localISO(new Date()));
|
||||||
|
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<TransactionRow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Selection state: id → direction. Presence in the map = selected.
|
||||||
|
const [selection, setSelection] = useState<
|
||||||
|
Map<number, BalanceTransferDirection>
|
||||||
|
>(new Map());
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fmt = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}),
|
||||||
|
[i18n.language]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-fetch whenever the filters change. Debounced via React's render cycle
|
||||||
|
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function run() {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await getTransactionPage(
|
||||||
|
{
|
||||||
|
search: search.trim(),
|
||||||
|
categoryId,
|
||||||
|
sourceId: null,
|
||||||
|
dateFrom: from || null,
|
||||||
|
dateTo: to || null,
|
||||||
|
uncategorizedOnly: false,
|
||||||
|
},
|
||||||
|
{ column: "date", direction: "desc" },
|
||||||
|
1,
|
||||||
|
DEFAULT_PAGE_SIZE
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setRows(result.rows);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [from, to, categoryId, search]);
|
||||||
|
|
||||||
|
function toggleRow(row: TransactionRow) {
|
||||||
|
setSelection((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
if (next.has(row.id)) {
|
||||||
|
next.delete(row.id);
|
||||||
|
} else {
|
||||||
|
next.set(row.id, suggestTransferDirection(row.amount));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flipDirection(rowId: number) {
|
||||||
|
setSelection((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(rowId);
|
||||||
|
if (current === undefined) return prev;
|
||||||
|
next.set(rowId, current === "in" ? "out" : "in");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (selection.size === 0) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
let linked = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const [transactionId, direction] of selection.entries()) {
|
||||||
|
try {
|
||||||
|
await linkTransfer(accountId, transactionId, direction);
|
||||||
|
linked += 1;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof BalanceServiceError) {
|
||||||
|
failures.push(`${transactionId}: ${t(`balance.transfers.errors.${e.code}`, { defaultValue: e.message })}`);
|
||||||
|
} else {
|
||||||
|
failures.push(`${transactionId}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
if (failures.length > 0) {
|
||||||
|
setSubmitError(
|
||||||
|
`${t("balance.transfers.modal.partialFailure", { linked, total: selection.size })} — ${failures.join("; ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (linked > 0) {
|
||||||
|
onLinked?.(linked);
|
||||||
|
if (failures.length === 0) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiltered = rows.length;
|
||||||
|
const selectedCount = selection.size;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{t("balance.transfers.modal.title", { account: accountName })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
|
||||||
|
{t("balance.transfers.modal.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-b border-[var(--border)] grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<label className="text-xs">
|
||||||
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.transfers.modal.from")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs">
|
||||||
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.transfers.modal.to")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs">
|
||||||
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.transfers.modal.category")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={categoryId === null ? "" : String(categoryId)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs">
|
||||||
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
|
{t("balance.transfers.modal.search")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("balance.transfers.modal.searchPlaceholder")}
|
||||||
|
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-[var(--muted-foreground)] flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
{t("balance.transfers.modal.loading")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-[var(--negative)] flex items-center justify-center gap-2">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("balance.transfers.modal.noTransactions")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[var(--muted)]/30 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-3 py-2"></th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
|
{t("transactions.date")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
|
{t("transactions.description")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">
|
||||||
|
{t("transactions.amount")}
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-3 py-2 font-medium">
|
||||||
|
{t("balance.transfers.modal.direction")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const isSelected = selection.has(row.id);
|
||||||
|
const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleRow(row)}
|
||||||
|
aria-label={`select-${row.id}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||||
|
<td className="px-3 py-2 max-w-md truncate" title={row.description}>
|
||||||
|
{row.description}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-2 text-right font-mono ${row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}
|
||||||
|
>
|
||||||
|
{fmt.format(row.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{isSelected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => flipDirection(row.id)}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded font-medium ${
|
||||||
|
direction === "in"
|
||||||
|
? "bg-[var(--positive)]/15 text-[var(--positive)]"
|
||||||
|
: "bg-[var(--negative)]/15 text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
title={t("balance.transfers.modal.toggleDirection")}
|
||||||
|
>
|
||||||
|
{t(`balance.transfers.direction.${direction}`)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t(`balance.transfers.direction.${direction}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="px-5 py-2 border-t border-[var(--border)] text-xs text-[var(--negative)]">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-between">
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.transfers.modal.summary", {
|
||||||
|
selected: selectedCount,
|
||||||
|
total: allFiltered,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting || selectedCount === 0}
|
||||||
|
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="animate-spin" size={14} />
|
||||||
|
{t("balance.transfers.modal.linking")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("balance.transfers.modal.linkSelection", { count: selectedCount })
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
|
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
|
||||||
// columns with a TODO comment.
|
// columns with a TODO comment.
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,10 +19,17 @@ import {
|
||||||
type BalancePeriod,
|
type BalancePeriod,
|
||||||
type BalanceChartMode,
|
type BalanceChartMode,
|
||||||
} from "../hooks/useBalanceOverview";
|
} 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 BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
|
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||||
|
|
||||||
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||||
|
|
||||||
|
|
@ -30,6 +37,58 @@ export default function BalancePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
|
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<AccountLatestSnapshot | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [transfersByAccount, setTransfersByAccount] = useState<
|
||||||
|
Map<number, BalanceAccountTransferWithTransaction[]>
|
||||||
|
>(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<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 —
|
// Build a category_key → translated label map from the accounts payload —
|
||||||
// the byCategory series is keyed by `key`, not by id, and the same
|
// the byCategory series is keyed by `key`, not by id, and the same
|
||||||
// taxonomy is already loaded with `accountsLatest` joins.
|
// taxonomy is already loaded with `accountsLatest` joins.
|
||||||
|
|
@ -123,6 +182,7 @@ export default function BalancePage() {
|
||||||
totals={state.evolutionTotals}
|
totals={state.evolutionTotals}
|
||||||
byCategory={state.evolutionByCategory}
|
byCategory={state.evolutionByCategory}
|
||||||
categoryLabels={categoryLabels}
|
categoryLabels={categoryLabels}
|
||||||
|
transferMarkers={allTransferMarkers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -132,10 +192,24 @@ export default function BalancePage() {
|
||||||
<BalanceAccountsTable
|
<BalanceAccountsTable
|
||||||
accounts={state.accountsLatest}
|
accounts={state.accountsLatest}
|
||||||
periodAnchor={state.accountsPeriodAnchor}
|
periodAnchor={state.accountsPeriodAnchor}
|
||||||
|
sinceCreationDate={earliestSnapshotDate}
|
||||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
|
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{linkTarget && (
|
||||||
|
<LinkTransfersModal
|
||||||
|
accountId={linkTarget.account_id}
|
||||||
|
accountName={linkTarget.account_name}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setLinkTarget(null)}
|
||||||
|
onLinked={() => {
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { loadProfiles } from "./profileService";
|
||||||
import type {
|
import type {
|
||||||
AccountReturn,
|
AccountReturn,
|
||||||
BalanceAccount,
|
BalanceAccount,
|
||||||
BalanceAccountTransfer,
|
|
||||||
BalanceAccountTransferWithTransaction,
|
BalanceAccountTransferWithTransaction,
|
||||||
BalanceAccountWithCategory,
|
BalanceAccountWithCategory,
|
||||||
BalanceCategory,
|
BalanceCategory,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue