// 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(null); const [search, setSearch] = useState(""); const [rows, setRows] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Selection state: id → direction. Presence in the map = selected. const [selection, setSelection] = useState< Map >(new Map()); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(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(
e.stopPropagation()} >

{t("balance.transfers.modal.title", { account: accountName })}

{t("balance.transfers.modal.subtitle")}

{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 ); }