Extends PR #189's fix (one input on /balance/snapshot) to the 7 remaining native <input type="date"> fields across 4 components: - transactions/TransactionFilterBar.tsx (dateFrom + dateTo) - adjustments/AdjustmentForm.tsx (form.date) - balance/LinkTransfersModal.tsx (from + to) - dashboard/PeriodSelector.tsx (localFrom + localTo) Each onChange handler now calls e.currentTarget.blur() after the state update to dismiss the native date popup on Linux Tauri WebView. The call is a no-op on Windows WebView2 / macOS WKWebView, where the picker already auto-closes. No automated test added: this is a WebKitGTK-specific WebView quirk that cannot be reproduced in jsdom/vitest. Manual smoke test on Linux Tauri dev was the validation, mirroring PR #189's approach. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
// 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);
|
|
// Close native date popup on WebKitGTK (#177)
|
|
e.currentTarget.blur();
|
|
}}
|
|
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);
|
|
// Close native date popup on WebKitGTK (#177)
|
|
e.currentTarget.blur();
|
|
}}
|
|
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
|
|
);
|
|
}
|