Simpl-Resultat/src/pages/TransactionsPage.tsx
le king fu 0e996a5aa1 feat(transactions): inline transfer icon + FK error message
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>
2026-04-25 16:38:46 -04:00

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