feat(balance): Modified Dietz returns + transfer linking (#142) #151
4 changed files with 196 additions and 7 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
import { Fragment, useState, useMemo } from "react";
|
import { Fragment, useState, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react";
|
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
TransactionSort,
|
TransactionSort,
|
||||||
Category,
|
Category,
|
||||||
SplitChild,
|
SplitChild,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
||||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||||
|
|
||||||
|
|
@ -22,6 +23,14 @@ interface TransactionTableProps {
|
||||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||||
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => 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({
|
function SortIcon({
|
||||||
|
|
@ -52,6 +61,7 @@ export default function TransactionTable({
|
||||||
onSaveSplit,
|
onSaveSplit,
|
||||||
onDeleteSplit,
|
onDeleteSplit,
|
||||||
onRowContextMenu,
|
onRowContextMenu,
|
||||||
|
linkedTransfersByTxId,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
@ -141,8 +151,31 @@ export default function TransactionTable({
|
||||||
className="hover:bg-[var(--muted)] transition-colors"
|
className="hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||||
<td className="px-3 py-2 max-w-xs truncate" title={row.description}>
|
<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}
|
{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>
|
||||||
<td
|
<td
|
||||||
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
|
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wand2, Tag } from "lucide-react";
|
import { Wand2, Tag } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
|
|
@ -9,6 +9,10 @@ import TransactionTable from "../components/transactions/TransactionTable";
|
||||||
import TransactionPagination from "../components/transactions/TransactionPagination";
|
import TransactionPagination from "../components/transactions/TransactionPagination";
|
||||||
import ContextMenu from "../components/shared/ContextMenu";
|
import ContextMenu from "../components/shared/ContextMenu";
|
||||||
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
||||||
|
import {
|
||||||
|
listAllLinkedTransfersForTooltip,
|
||||||
|
type LinkedTransferTooltipRow,
|
||||||
|
} from "../services/balance.service";
|
||||||
import type { TransactionRow } from "../shared/types";
|
import type { TransactionRow } from "../shared/types";
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
|
|
@ -18,6 +22,18 @@ export default function TransactionsPage() {
|
||||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
||||||
const [pending, setPending] = useState<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) => {
|
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -95,6 +111,7 @@ export default function TransactionsPage() {
|
||||||
onSaveSplit={saveSplit}
|
onSaveSplit={saveSplit}
|
||||||
onDeleteSplit={deleteSplit}
|
onDeleteSplit={deleteSplit}
|
||||||
onRowContextMenu={handleRowContextMenu}
|
onRowContextMenu={handleRowContextMenu}
|
||||||
|
linkedTransfersByTxId={linkedTransfersByTxId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionPagination
|
<TransactionPagination
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { isLinkedTransactionFkError } from "./balance.service";
|
||||||
|
import { TransactionLinkedToBalanceError } from "./transactionService";
|
||||||
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
|
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
|
||||||
|
|
||||||
export async function getFilesBySourceId(
|
export async function getFilesBySourceId(
|
||||||
|
|
@ -94,10 +96,39 @@ export async function deleteImportWithTransactions(
|
||||||
);
|
);
|
||||||
const sourceId = files.length > 0 ? files[0].source_id : null;
|
const sourceId = files.length > 0 ? files[0].source_id : null;
|
||||||
|
|
||||||
const result = await db.execute(
|
// Pre-flight: if any transaction in this file is linked to a balance
|
||||||
|
// account via `balance_account_transfers`, the FK RESTRICT will fire on
|
||||||
|
// the bulk DELETE. Surface a typed error BEFORE touching the row so the
|
||||||
|
// UI can prompt the user to unlink first (Issue #142).
|
||||||
|
const linked = await db.select<
|
||||||
|
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
|
||||||
|
>(
|
||||||
|
`SELECT bat.transaction_id AS transaction_id,
|
||||||
|
bat.account_id AS account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
bat.direction AS direction
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN transactions t ON t.id = bat.transaction_id
|
||||||
|
JOIN balance_accounts a ON a.id = bat.account_id
|
||||||
|
WHERE t.file_id = $1`,
|
||||||
|
[fileId]
|
||||||
|
);
|
||||||
|
if (linked.length > 0) {
|
||||||
|
throw new TransactionLinkedToBalanceError(null, linked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await db.execute(
|
||||||
"DELETE FROM transactions WHERE file_id = $1",
|
"DELETE FROM transactions WHERE file_id = $1",
|
||||||
[fileId]
|
[fileId]
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (isLinkedTransactionFkError(err)) {
|
||||||
|
throw new TransactionLinkedToBalanceError(null, []);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
|
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
|
||||||
|
|
||||||
// Clean up orphaned source if no files remain
|
// Clean up orphaned source if no files remain
|
||||||
|
|
@ -116,7 +147,32 @@ export async function deleteImportWithTransactions(
|
||||||
|
|
||||||
export async function deleteAllImportsWithTransactions(): Promise<number> {
|
export async function deleteAllImportsWithTransactions(): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const result = await db.execute("DELETE FROM transactions");
|
// Same pre-flight as the per-file path: if ANY transaction is linked to
|
||||||
|
// a balance account, the bulk wipe would explode on FK RESTRICT — surface
|
||||||
|
// a typed error so the UI can prompt the user to unlink first.
|
||||||
|
const linked = await db.select<
|
||||||
|
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
|
||||||
|
>(
|
||||||
|
`SELECT bat.transaction_id AS transaction_id,
|
||||||
|
bat.account_id AS account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
bat.direction AS direction
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN balance_accounts a ON a.id = bat.account_id
|
||||||
|
LIMIT 50`
|
||||||
|
);
|
||||||
|
if (linked.length > 0) {
|
||||||
|
throw new TransactionLinkedToBalanceError(null, linked);
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await db.execute("DELETE FROM transactions");
|
||||||
|
} catch (err) {
|
||||||
|
if (isLinkedTransactionFkError(err)) {
|
||||||
|
throw new TransactionLinkedToBalanceError(null, []);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
await db.execute("DELETE FROM imported_files");
|
await db.execute("DELETE FROM imported_files");
|
||||||
await db.execute("DELETE FROM import_sources");
|
await db.execute("DELETE FROM import_sources");
|
||||||
return result.rowsAffected;
|
return result.rowsAffected;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { categorizeBatch } from "./categorizationService";
|
import { categorizeBatch } from "./categorizationService";
|
||||||
|
import {
|
||||||
|
isLinkedTransactionFkError,
|
||||||
|
type LinkedTransferTooltipRow,
|
||||||
|
} from "./balance.service";
|
||||||
import type {
|
import type {
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
|
|
@ -11,6 +15,85 @@ import type {
|
||||||
SplitChild,
|
SplitChild,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a deletion path is blocked by `balance_account_transfers.transaction_id`
|
||||||
|
* FK RESTRICT. Carries the offending `transaction_id`(s) so the UI can format a
|
||||||
|
* precise message ("Cette transaction est liée au compte de bilan X — déliez-la
|
||||||
|
* avant de supprimer") and can offer a deep link to the linked account.
|
||||||
|
*
|
||||||
|
* `linkedAccounts` is best-effort: when known (single-row delete) the array
|
||||||
|
* lists every account currently linking the transaction. For bulk deletes
|
||||||
|
* the array may be empty — the UI just shows the generic message in that
|
||||||
|
* case.
|
||||||
|
*/
|
||||||
|
export class TransactionLinkedToBalanceError extends Error {
|
||||||
|
readonly code = "transaction_linked_to_balance_account" as const;
|
||||||
|
readonly transactionId: number | null;
|
||||||
|
readonly linkedAccounts: LinkedTransferTooltipRow[];
|
||||||
|
constructor(
|
||||||
|
transactionId: number | null,
|
||||||
|
linkedAccounts: LinkedTransferTooltipRow[],
|
||||||
|
message?: string
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
message ??
|
||||||
|
"Transaction is linked to one or more balance accounts; unlink before deleting"
|
||||||
|
);
|
||||||
|
this.name = "TransactionLinkedToBalanceError";
|
||||||
|
this.transactionId = transactionId;
|
||||||
|
this.linkedAccounts = linkedAccounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one transaction by id. Throws `TransactionLinkedToBalanceError` if
|
||||||
|
* the transaction has any row in `balance_account_transfers` (FK RESTRICT)
|
||||||
|
* — UI surfaces "Cette transaction est liée au compte de bilan X — déliez-la
|
||||||
|
* avant de supprimer" with a link to the offending account.
|
||||||
|
*
|
||||||
|
* Pre-checks the link table so the error carries account names; falls back
|
||||||
|
* to the FK-error pattern matcher if the constraint fires for any other
|
||||||
|
* reason.
|
||||||
|
*/
|
||||||
|
export async function deleteTransaction(transactionId: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
// Pre-check: if any transfer references this transaction, surface a clean
|
||||||
|
// typed error WITHOUT touching the row. Cheaper than catching the FK
|
||||||
|
// exception and provides the account names for the UI message.
|
||||||
|
const linked = await db.select<
|
||||||
|
Array<{ account_id: number; account_name: string; direction: "in" | "out" }>
|
||||||
|
>(
|
||||||
|
`SELECT bat.account_id AS account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
bat.direction AS direction
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN balance_accounts a ON a.id = bat.account_id
|
||||||
|
WHERE bat.transaction_id = $1`,
|
||||||
|
[transactionId]
|
||||||
|
);
|
||||||
|
if (linked.length > 0) {
|
||||||
|
throw new TransactionLinkedToBalanceError(
|
||||||
|
transactionId,
|
||||||
|
linked.map((l) => ({
|
||||||
|
transaction_id: transactionId,
|
||||||
|
account_id: l.account_id,
|
||||||
|
account_name: l.account_name,
|
||||||
|
direction: l.direction,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.execute("DELETE FROM transactions WHERE id = $1", [transactionId]);
|
||||||
|
} catch (err) {
|
||||||
|
// Defensive: a race could have linked the transaction between the
|
||||||
|
// SELECT and the DELETE. Surface the typed error in that case too.
|
||||||
|
if (isLinkedTransactionFkError(err)) {
|
||||||
|
throw new TransactionLinkedToBalanceError(transactionId, []);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function insertBatch(
|
export async function insertBatch(
|
||||||
transactions: Array<{
|
transactions: Array<{
|
||||||
date: string;
|
date: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue