Simpl-Resultat/src/components/transactions/TransactionTable.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

318 lines
13 KiB
TypeScript

import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
import type {
TransactionRow,
TransactionSort,
Category,
SplitChild,
} from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import CategoryCombobox from "../shared/CategoryCombobox";
import SplitAdjustmentModal from "./SplitAdjustmentModal";
interface TransactionTableProps {
rows: TransactionRow[];
sort: TransactionSort;
categories: Category[];
onSort: (column: TransactionSort["column"]) => void;
onCategoryChange: (txId: number, categoryId: number | null) => void;
onNotesChange: (txId: number, notes: string) => void;
onAddKeyword: (categoryId: number, keyword: string) => Promise<void>;
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
/**
* Issue #142 — when supplied, a small Link2 icon appears next to the
* description for every transaction whose id is a key in the map. The
* icon's tooltip lists the linked accounts. The lookup is intentionally
* done by the parent (one batch SELECT, in-memory `.has()` thereafter)
* to avoid an N+1 hit on the table render.
*/
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
}
function SortIcon({
column,
sort,
}: {
column: TransactionSort["column"];
sort: TransactionSort;
}) {
if (sort.column !== column)
return <span className="ml-1 text-[var(--muted-foreground)] opacity-30">&#8597;</span>;
return sort.direction === "asc" ? (
<ChevronUp size={14} className="ml-1 inline" />
) : (
<ChevronDown size={14} className="ml-1 inline" />
);
}
export default function TransactionTable({
rows,
sort,
categories,
onSort,
onCategoryChange,
onNotesChange,
onAddKeyword,
onLoadSplitChildren,
onSaveSplit,
onDeleteSplit,
onRowContextMenu,
linkedTransfersByTxId,
}: TransactionTableProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editingNotes, setEditingNotes] = useState("");
const [keywordRowId, setKeywordRowId] = useState<number | null>(null);
const [keywordText, setKeywordText] = useState("");
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
const noCategoryExtra = useMemo(
() => [{ value: "", label: t("transactions.table.noCategory") }],
[t]
);
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("transactions.noTransactions")}</p>
</div>
);
}
const columns: Array<{
key: TransactionSort["column"];
label: string;
align: string;
}> = [
{ key: "date", label: t("transactions.date"), align: "text-left" },
{ key: "description", label: t("transactions.description"), align: "text-left" },
{ key: "amount", label: t("transactions.amount"), align: "text-right" },
{ key: "category_name", label: t("transactions.category"), align: "text-left" },
];
const toggleNotes = (row: TransactionRow) => {
if (expandedId === row.id) {
setExpandedId(null);
} else {
setExpandedId(row.id);
setEditingNotes(row.notes ?? "");
}
};
const handleNotesSave = (txId: number) => {
onNotesChange(txId, editingNotes);
setExpandedId(null);
};
const toggleKeyword = (row: TransactionRow) => {
if (keywordRowId === row.id) {
setKeywordRowId(null);
} else {
setKeywordRowId(row.id);
setKeywordText(row.description);
}
};
const handleKeywordSave = async (row: TransactionRow) => {
if (!row.category_id || !keywordText.trim()) return;
await onAddKeyword(row.category_id, keywordText);
setKeywordRowId(null);
setKeywordSaved(row.id);
setTimeout(() => setKeywordSaved(null), 2000);
};
return (
<div className="overflow-x-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--muted)]">
{columns.map((col) => (
<th
key={col.key}
onClick={() => onSort(col.key)}
className={`px-3 py-2 ${col.align} text-xs font-medium text-[var(--muted-foreground)] cursor-pointer select-none hover:text-[var(--foreground)] transition-colors`}
>
{col.label}
<SortIcon column={col.key} sort={sort} />
</th>
))}
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{rows.map((row) => (
<Fragment key={row.id}>
<tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
className="hover:bg-[var(--muted)] transition-colors"
>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-xs">
<div className="flex items-center gap-1.5">
<span className="truncate" title={row.description}>
{row.description}
</span>
{linkedTransfersByTxId?.has(row.id) && (
<span
className="inline-flex items-center text-[var(--primary)] shrink-0"
title={
// Build a human-readable list: "TFSA (in), RRSP (out)".
(() => {
const links = linkedTransfersByTxId.get(row.id) ?? [];
const parts = links.map(
(l) =>
`${l.account_name} (${t(`balance.transfers.direction.${l.direction}`)})`
);
return `${t("transactions.transferIcon.tooltip")}: ${parts.join(", ")}`;
})()
}
aria-label={t("transactions.transferIcon.ariaLabel")}
>
<Link2 size={12} />
</span>
)}
</div>
</td>
<td
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{row.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<CategoryCombobox
categories={categories}
value={row.category_id}
onChange={(id) => onCategoryChange(row.id, id)}
placeholder={t("transactions.table.noCategory")}
compact
extraOptions={noCategoryExtra}
activeExtra={row.category_id === null ? "" : null}
onExtraSelect={() => onCategoryChange(row.id, null)}
/>
{row.category_id !== null && (
<>
<button
onClick={() => toggleKeyword(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
keywordSaved === row.id
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
>
<Tag size={14} />
</button>
<button
onClick={() => setSplitRow(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
row.is_split
? "text-orange-500"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.splitAdjustment")}
>
<Split size={14} />
</button>
</>
)}
</div>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => toggleNotes(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
row.notes
? "text-orange-500"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.notes.placeholder")}
>
<MessageSquare size={14} />
</button>
</td>
</tr>
{expandedId === row.id && (
<tr>
<td colSpan={5} className="px-3 py-2 bg-[var(--muted)]">
<div className="flex gap-2">
<textarea
value={editingNotes}
onChange={(e) => setEditingNotes(e.target.value)}
placeholder={t("transactions.notes.placeholder")}
rows={2}
className="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)] resize-none"
/>
<button
onClick={() => handleNotesSave(row.id)}
className="px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity self-end"
>
{t("common.save")}
</button>
</div>
</td>
</tr>
)}
{keywordRowId === row.id && row.category_id !== null && (
<tr>
<td colSpan={5} className="px-3 py-2 bg-[var(--muted)]">
<div className="flex items-center gap-2">
<Tag size={14} className="text-[var(--muted-foreground)] shrink-0" />
<span className="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--foreground)] shrink-0">
{row.category_name}
</span>
<input
type="text"
value={keywordText}
onChange={(e) => setKeywordText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleKeywordSave(row);
if (e.key === "Escape") setKeywordRowId(null);
}}
placeholder={t("transactions.keywordPlaceholder")}
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
autoFocus
/>
<button
onClick={() => handleKeywordSave(row)}
disabled={!keywordText.trim()}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{t("common.save")}
</button>
<button
onClick={() => setKeywordRowId(null)}
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors"
>
{t("common.cancel")}
</button>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
{splitRow && (
<SplitAdjustmentModal
transaction={splitRow}
categories={categories}
onLoadChildren={onLoadSplitChildren}
onSave={onSaveSplit}
onDelete={onDeleteSplit}
onClose={() => setSplitRow(null)}
/>
)}
</div>
);
}