Issue #142 / Bilan #4 — non-regressive transfer awareness in the transactions table + clean error mapping on bulk delete. - `TransactionTable.tsx`: optional new prop `linkedTransfersByTxId?: Map<txId, links[]>`. When supplied, a small `<Link2>` icon appears next to the description for every linked transaction; tooltip lists the account name(s) and direction(s). Without the prop, the table renders byte-for-byte identical to before — preserves the spec's non-regression invariant. - `TransactionsPage.tsx`: loads the linked-transfers map once on mount via `listAllLinkedTransfersForTooltip()` (one batch SELECT) and threads it through to the table. Failure to load the map degrades gracefully to an empty map (icon simply doesn't appear). - `transactionService.ts`: new `deleteTransaction(id)` helper + `TransactionLinkedToBalanceError` (typed FK guard). Pre-checks `balance_account_transfers` before attempting the DELETE so the error carries the offending account names; falls back to the FK pattern matcher if a race linked the transaction between the SELECT and the DELETE. - `importedFileService.ts`: both bulk delete paths (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`) now pre-check for any linked transfer and surface the same typed error before they would explode on FK RESTRICT. The pre-check has a `LIMIT 50` safety cap on the global path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Wand2, Tag } from "lucide-react";
|
|
import { PageHelp } from "../components/shared/PageHelp";
|
|
import { useTransactions } from "../hooks/useTransactions";
|
|
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
|
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
|
import TransactionTable from "../components/transactions/TransactionTable";
|
|
import TransactionPagination from "../components/transactions/TransactionPagination";
|
|
import ContextMenu from "../components/shared/ContextMenu";
|
|
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
|
import {
|
|
listAllLinkedTransfersForTooltip,
|
|
type LinkedTransferTooltipRow,
|
|
} from "../services/balance.service";
|
|
import type { TransactionRow } from "../shared/types";
|
|
|
|
export default function TransactionsPage() {
|
|
const { t } = useTranslation();
|
|
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
|
useTransactions();
|
|
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
|
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
|
const [pending, setPending] = useState<TransactionRow | null>(null);
|
|
// Issue #142 — single batch lookup for the inlined transfer icon. One
|
|
// SELECT on mount gives us a Map<txId, links[]> the table consults via
|
|
// `.has()` per row. Avoids an N+1 hit on the rendered page.
|
|
const [linkedTransfersByTxId, setLinkedTransfersByTxId] = useState<
|
|
Map<number, LinkedTransferTooltipRow[]>
|
|
>(new Map());
|
|
|
|
useEffect(() => {
|
|
listAllLinkedTransfersForTooltip()
|
|
.then(setLinkedTransfersByTxId)
|
|
.catch(() => setLinkedTransfersByTxId(new Map()));
|
|
}, []);
|
|
|
|
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
|
|
e.preventDefault();
|
|
setMenu({ x: e.clientX, y: e.clientY, row });
|
|
};
|
|
|
|
const handleAutoCategorize = async () => {
|
|
setResultMessage(null);
|
|
const count = await autoCategorize();
|
|
if (count > 0) {
|
|
setResultMessage(t("transactions.autoCategorizeResult", { count }));
|
|
} else {
|
|
setResultMessage(t("transactions.autoCategorizeNone"));
|
|
}
|
|
setTimeout(() => setResultMessage(null), 4000);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="relative flex items-center gap-3 mb-6">
|
|
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
|
|
<PageHelp helpKey="transactions" />
|
|
<button
|
|
onClick={handleAutoCategorize}
|
|
disabled={state.isAutoCategorizing}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
|
>
|
|
<Wand2 size={16} />
|
|
{state.isAutoCategorizing
|
|
? t("common.loading")
|
|
: t("transactions.autoCategorize")}
|
|
</button>
|
|
{resultMessage && (
|
|
<span className="text-sm text-[var(--muted-foreground)]">
|
|
{resultMessage}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<TransactionFilterBar
|
|
filters={state.filters}
|
|
categories={state.categories}
|
|
sources={state.sources}
|
|
onFilterChange={setFilter}
|
|
/>
|
|
|
|
<TransactionSummaryBar
|
|
totalCount={state.totalCount}
|
|
totalAmount={state.totalAmount}
|
|
incomeTotal={state.incomeTotal}
|
|
expenseTotal={state.expenseTotal}
|
|
/>
|
|
|
|
{state.error && (
|
|
<div className="mb-4 p-3 rounded-lg bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))] border border-[var(--negative)] text-[var(--negative)] text-sm">
|
|
{state.error}
|
|
</div>
|
|
)}
|
|
|
|
{state.isLoading ? (
|
|
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
|
{t("common.loading")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<TransactionTable
|
|
rows={state.rows}
|
|
sort={state.sort}
|
|
categories={state.categories}
|
|
onSort={setSort}
|
|
onCategoryChange={updateCategory}
|
|
onNotesChange={saveNotes}
|
|
onAddKeyword={addKeywordToCategory}
|
|
onLoadSplitChildren={loadSplitChildren}
|
|
onSaveSplit={saveSplit}
|
|
onDeleteSplit={deleteSplit}
|
|
onRowContextMenu={handleRowContextMenu}
|
|
linkedTransfersByTxId={linkedTransfersByTxId}
|
|
/>
|
|
|
|
<TransactionPagination
|
|
page={state.page}
|
|
pageSize={state.pageSize}
|
|
totalCount={state.totalCount}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{menu && (
|
|
<ContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
header={menu.row.description}
|
|
onClose={() => setMenu(null)}
|
|
items={[
|
|
{
|
|
icon: <Tag size={14} />,
|
|
label: t("reports.keyword.addFromTransaction"),
|
|
onClick: () => setPending(menu.row),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{pending && (
|
|
<AddKeywordDialog
|
|
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
|
initialCategoryId={pending.category_id}
|
|
onClose={() => setPending(null)}
|
|
onApplied={() => setPending(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|