feat: Ein verknuepftes Paar steht als EINE Zeile in der Buchungsliste

Ein Uebertrag ist eine Bewegung, keine zwei. In der Liste standen aber beide
Seiten untereinander, jede mit der Marke des Gegenkontos – man musste sie
selbst zusammensetzen, und es sah aus wie eine Dublette.

Liegen beide Seiten in der Liste, wird die Zugangs-Seite ausgeblendet und die
Abgangs-Seite zeigt "Revolut CHF → Sparen Pocket" – so wie ein echter
Uebertrag. Ueber zwei Waehrungen stehen beide Betraege untereinander
("CHF 400.00" / "→ 425,32 €"); nebeneinander passen sie nicht in die Spalte.

In der Datenbank aendert sich nichts: Beide Buchungen bleiben stehen, mit ihren
Import-Schluesseln und ihrer Stapel-Zugehoerigkeit. Das ist die tragende
Eigenschaft der Verknuepfung, und sie bleibt.

Ist nur eine Seite geladen – etwa weil nach einem Konto gefiltert wird –,
bleibt sie einzeln stehen und traegt weiter die Marke mit dem Gegenkonto.
Sonst verschwaende dort eine Buchung aus der Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 13:18:22 +02:00
co-authored by Claude Opus 5
parent e0d8882851
commit 7fe2678192
+61 -4
View File
@@ -13,7 +13,7 @@ import CategorySelect from "../components/CategorySelect";
import UebertragDialog from "../components/UebertragDialog"; import UebertragDialog from "../components/UebertragDialog";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien"; import { gruppenFuer } from "../kategorien";
import { centsToInput, parseAmountToCents, todayIso } from "../format"; import { centsToInput, formatMoney, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"]; const TYPES = ["expense", "income", "transfer"];
@@ -256,7 +256,36 @@ export default function Transactions() {
try { await api.deleteTransaction(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } try { await api.deleteTransaction(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); }
} }
// Ein verknüpftes Paar ist EINE Bewegung, keine zwei. Liegen beide Seiten in
// der Liste, wird die Zugangs-Seite ausgeblendet und die Abgangs-Seite zeigt
// „von → nach" – so wie ein echter Übertrag. Ist nur eine Seite geladen
// (etwa weil nach einem Konto gefiltert wird), bleibt sie einzeln stehen;
// dann trägt sie weiter die Marke mit dem Gegenkonto.
const { zeilen, paarZu } = useMemo(() => {
const vorhanden = new Set(txns.map((t) => t.id));
const paare = {};
const sichtbar = [];
for (const tx of txns) {
const link = links[String(tx.id)];
const gegenseiteDa = link && vorhanden.has(link.other_id);
if (gegenseiteDa && link.richtung === "rein") continue; // von der anderen gezeigt
if (gegenseiteDa) paare[tx.id] = txns.find((x) => x.id === link.other_id);
sichtbar.push(tx);
}
return { zeilen: sichtbar, paarZu: paare };
}, [txns, links]);
function rowLabel(tx) { function rowLabel(tx) {
const gegen = paarZu[tx.id];
if (gegen) {
const von = accountById[tx.account_id]?.name || "?";
const nach = accountById[gegen.account_id]?.name || "?";
return {
icon: "arrow-left-right", color: "#6b7480",
title: `${von} → ${nach}`,
sub: tx.payee || tx.description || t("transactions.transfer"),
};
}
if (tx.type === "transfer") { if (tx.type === "transfer") {
const from = accountById[tx.account_id]?.name || "?"; const from = accountById[tx.account_id]?.name || "?";
const to = accountById[tx.to_account_id]?.name || "?"; const to = accountById[tx.to_account_id]?.name || "?";
@@ -307,7 +336,9 @@ export default function Transactions() {
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>} {l.sub && <div className="named-sub ellipsis">{l.sub}</div>}
{/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme.
Ohne das Gegenkonto sagt „Übertrag" nichts. */} Ohne das Gegenkonto sagt „Übertrag" nichts. */}
{links[String(tx.id)] && ( {/* Bei einer Paar-Zeile steht das Gegenkonto schon im Titel –
die Marke waere dort nur eine Wiederholung. */}
{links[String(tx.id)] && !paarZu[tx.id] && (
<div className="named-sub ellipsis uebertrag-marke"> <div className="named-sub ellipsis uebertrag-marke">
<Icon name="arrow-left-right" size={12} />{" "} <Icon name="arrow-left-right" size={12} />{" "}
{accountById[links[String(tx.id)].other_account_id]?.name {accountById[links[String(tx.id)].other_account_id]?.name
@@ -372,6 +403,32 @@ export default function Transactions() {
sortValue: (tx) => tx.amount_base_cents, sortValue: (tx) => tx.amount_base_cents,
render: (tx) => { render: (tx) => {
const acc = accountById[tx.account_id]; const acc = accountById[tx.account_id];
const gegen = paarZu[tx.id];
if (gegen) {
// Ein Paar hat kein Vorzeichen – es verschiebt. Bei verschiedenen
// Währungen stehen beide Beträge, sonst verschwände einer.
const zielKonto = accountById[gegen.account_id];
const andereWaehrung = zielKonto?.currency !== acc?.currency;
if (!andereWaehrung) {
return (
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} />
);
}
// Über zwei Währungen gehören beide Beträge dazu – nebeneinander
// passen sie nicht in die Spalte, untereinander schon. Dieselbe
// Anordnung nutzt der umgerechnete Wert ohnehin.
return (
<span className="money">
<span className="amount">
{formatMoney(tx.amount_cents, acc?.currency || "EUR")}
</span>
<span className="amount-converted">
→ {formatMoney(gegen.amount_cents, zielKonto?.currency || "EUR")}
</span>
</span>
);
}
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null; const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return ( return (
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"} <Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
@@ -413,7 +470,7 @@ export default function Transactions() {
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups, ], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
formatDate, links, vorschlag, t]); formatDate, links, paarZu, vorschlag, t]);
return ( return (
<div> <div>
@@ -445,7 +502,7 @@ export default function Transactions() {
<div className="card"> <div className="card">
<DataTable <DataTable
id="transactions-v2" id="transactions-v2"
rows={txns} rows={zeilen}
getRowKey={(tx) => tx.id} getRowKey={(tx) => tx.id}
empty={t("transactions.empty")} empty={t("transactions.empty")}
columns={txnColumns} columns={txnColumns}