From 7fe26781924bb36047665b87eac9b2ad4397b931 Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 13:18:22 +0200 Subject: [PATCH] feat: Ein verknuepftes Paar steht als EINE Zeile in der Buchungsliste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/pages/Transactions.jsx | 65 +++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index c0f3a5d..5869602 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -13,7 +13,7 @@ import CategorySelect from "../components/CategorySelect"; import UebertragDialog from "../components/UebertragDialog"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; -import { centsToInput, parseAmountToCents, todayIso } from "../format"; +import { centsToInput, formatMoney, parseAmountToCents, todayIso } from "../format"; 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"); } } + // 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) { + 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") { const from = accountById[tx.account_id]?.name || "?"; const to = accountById[tx.to_account_id]?.name || "?"; @@ -307,7 +336,9 @@ export default function Transactions() { {l.sub &&
{l.sub}
} {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. 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] && (
{" "} {accountById[links[String(tx.id)].other_account_id]?.name @@ -372,6 +403,32 @@ export default function Transactions() { sortValue: (tx) => tx.amount_base_cents, render: (tx) => { 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 ( + + ); + } + // Ü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 ( + + + {formatMoney(tx.amount_cents, acc?.currency || "EUR")} + + + → {formatMoney(gegen.amount_cents, zielKonto?.currency || "EUR")} + + + ); + } const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null; return ( @@ -445,7 +502,7 @@ export default function Transactions() {
tx.id} empty={t("transactions.empty")} columns={txnColumns}