From eb72f0cb27c6c412c804619e8c38cb4f195e91db Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 1 Oct 2026 20:49:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Einer=20Buchung=20sieht=20man=20an,=20o?= =?UTF-8?q?b=20sie=20ein=20Uebertrag=20ist=20=E2=80=93=20und=20was=20sie?= =?UTF-8?q?=20war?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Dinge, die zusammengehoeren. 1. Uebertraege sind als solche lesbar. Stand nur eine Seite in der Liste – weil nach einem Konto gefiltert wird oder die andere nicht mitgeladen war – trug die Zeile den Rohtext der Bank als Ueberschrift. "To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c" sah aus wie eine Zahlung an jemanden; die Verknuepfung stand klein darunter. Jetzt steht oben, zwischen WELCHEN Konten das Geld lief, in beiden Faellen gleich, und daneben eine Marke "Transfer". Ein Uebertrag ist weder Ausgabe noch Einnahme – das gehoert in die erste Zeile, nicht in eine Fussnote. 2. Notiz je Buchung. Das Feld gab es im Modell seit jeher, die Oberflaeche bot es nie an. Die Kategorie sagt, in welche Schublade etwas gehoert – nicht, WAS es war. "Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk, und in drei Monaten weiss das niemand mehr. Die Notiz steht in der Liste und ist mitdurchsuchbar: Wer sie schreibt, will spaeter danach suchen. 3. "Ignorieren" ist keine Einbahnstrasse mehr. Ein abgelehntes Paar blieb bei jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in der Erkennung, den es nicht gab. Die abgelehnten Paare lassen sich jetzt ansehen und zurueckholen. Und wenn die Vorschlaege nicht geladen werden koennen, wird das gesagt statt verschluckt – sonst sieht die Liste aus, als gaebe es nichts zu verbinden. Co-Authored-By: Claude Opus 5 --- backend/app/routers/uebertraege.py | 60 ++++++++ backend/tests/test_notiz_und_ignoriert.py | 137 +++++++++++++++++++ web/src/api.js | 5 + web/src/components/Uebertragsvorschlaege.jsx | 48 ++++++- web/src/finance.css | 18 +++ web/src/locales/de.json | 8 ++ web/src/locales/en.json | 8 ++ web/src/pages/Transactions.jsx | 72 ++++++++-- 8 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 backend/tests/test_notiz_und_ignoriert.py diff --git a/backend/app/routers/uebertraege.py b/backend/app/routers/uebertraege.py index be37aec..8d535c8 100644 --- a/backend/app/routers/uebertraege.py +++ b/backend/app/routers/uebertraege.py @@ -85,6 +85,66 @@ def verbinden( "automatisch": False} +@router.get("/ignoriert") +def ignorierte(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: + """Welche Vorschlaege wurden abgelehnt? + + Ohne diese Liste war „Ignorieren" eine Einbahnstrasse: Das Paar blieb bei + jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen + Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in + der Erkennung, den es nicht gab. + """ + eintraege = ( + db.query(LinkIgnored) + .filter(LinkIgnored.owner_id == user.id) + .order_by(LinkIgnored.id.desc()) + .all() + ) + ids = {i for e in eintraege for i in (e.a_txn_id, e.b_txn_id)} + buchungen = { + t.id: t for t in db.query(Transaction).filter(Transaction.id.in_(ids or [0])) + } + namen = dict(db.query(Account.id, Account.name).filter(Account.owner_id == user.id)) + + def kurz(txn_id: int) -> dict | None: + t = buchungen.get(txn_id) + if t is None: + return None + return { + "id": t.id, "date": t.date.isoformat(), "amount_cents": t.amount_cents, + "payee": t.payee or t.description, + "account_name": namen.get(t.account_id, "?"), + } + + ergebnis = [] + for e in eintraege: + a, b = kurz(e.a_txn_id), kurz(e.b_txn_id) + # Ist eine der beiden Buchungen geloescht, gibt es nichts mehr + # zurueckzunehmen – der Eintrag waere nur noch Rauschen. + if a and b: + ergebnis.append({"a": a, "b": b}) + return ergebnis + + +@router.delete("/ignoriert", status_code=status.HTTP_204_NO_CONTENT) +def ignorieren_aufheben( + payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) +) -> None: + """Die Ablehnung zuruecknehmen – das Paar wird wieder vorgeschlagen.""" + a, b = _paar(db, user, payload) + klein, gross = sorted((a.id, b.id)) + ( + db.query(LinkIgnored) + .filter(LinkIgnored.owner_id == user.id, + LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross) + .delete() + ) + db.commit() + + +# Vor der Route mit dem Platzhalter, sonst verschluckt "/{link_id}" den +# wortwoertlichen Pfad: FastAPI nimmt die erste passende Route, und +# "ignoriert" laesst sich nicht als Zahl lesen (422 statt 204). @router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) def loesen(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Verknüpfung entfernen. Die beiden Buchungen bleiben, wie sie sind – diff --git a/backend/tests/test_notiz_und_ignoriert.py b/backend/tests/test_notiz_und_ignoriert.py new file mode 100644 index 0000000..b549343 --- /dev/null +++ b/backend/tests/test_notiz_und_ignoriert.py @@ -0,0 +1,137 @@ +"""Zwei Dinge, die es brauchte, damit man einer Buchung ansieht, was sie ist. + +Die Notiz: Die Kategorie sagt, in welche Schublade es gehoert – nicht, WAS es +war. „Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk. + +Der Rueckweg aus „Ignorieren": Bisher war ein Klick endgueltig. Das Paar blieb +bei jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen +Vorschlag gab. +""" + +from datetime import date + + +def _konto(admin, name, **kw): + body = {"name": name, "type": "girokonto", "currency": "EUR"} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _buchung(admin, konto, art, betrag, wer, tag="2026-05-04", **kw): + body = {"type": art, "date": tag, "account_id": konto["id"], + "amount_cents": betrag, "payee": wer} + body.update(kw) + r = admin.post("/api/transactions", json=body) + assert r.status_code in (200, 201), r.text + return r.json() + + +# --- Notiz ------------------------------------------------------------------ + +def test_notiz_laesst_sich_setzen_aendern_und_loeschen(admin): + konto = _konto(admin, "Notiz-Konto") + txn = _buchung(admin, konto, "expense", 4990, "Amazon", + notes="Schraubenset, kein Moebelstueck") + assert txn["notes"] == "Schraubenset, kein Moebelstueck" + + r = admin.patch(f"/api/transactions/{txn['id']}", json={"notes": "Geschenk fuer Tim"}) + assert r.json()["notes"] == "Geschenk fuer Tim" + + r = admin.patch(f"/api/transactions/{txn['id']}", json={"notes": None}) + assert r.json()["notes"] is None + + +def test_notiz_ueberlebt_eine_aenderung_am_betrag(admin): + """Sie haengt an der Buchung, nicht an einer Nebensache.""" + konto = _konto(admin, "Notiz-Konto 2") + txn = _buchung(admin, konto, "expense", 1000, "Amazon", notes="beruflich") + r = admin.patch(f"/api/transactions/{txn['id']}", json={"amount_cents": 1200}) + assert r.json()["notes"] == "beruflich" + + +def test_notiz_steht_in_der_liste(admin): + """Nach dem eigenen Konto gefragt – sonst haengt der Test an der Anzahl + Buchungen, die andere Tests vorher angelegt haben.""" + konto = _konto(admin, "Notiz-Konto 3") + _buchung(admin, konto, "expense", 2000, "Amazon", notes="Werkzeug") + zeilen = admin.get(f"/api/transactions?account_id={konto['id']}").json() + assert [t for t in zeilen if t.get("notes") == "Werkzeug"], "ohne das Feld in der Liste nuetzt die Notiz beim Suchen nichts" + + +# --- Abgelehnte Vorschlaege ------------------------------------------------- + +def _paar(admin, name): + a = _konto(admin, f"{name} A") + b = _konto(admin, f"{name} B") + # Mit Kategorie, damit nicht automatisch verknuepft wird: Dann bleibt es + # ein Vorschlag, und genau den kann man ablehnen. + kat = None + for g in admin.get("/api/categories").json(): + for c in g["categories"]: + if c["type"] == "expense": + kat = c["id"] + break + if kat: + break + raus = _buchung(admin, a, "expense", 777, "Umbuchung X", category_id=kat) + rein = _buchung(admin, b, "income", 777, "Umbuchung X") + return raus, rein + + +def test_abgelehntes_paar_laesst_sich_zurueckholen(admin): + raus, rein = _paar(admin, "Ignoriert") + + vor = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + assert any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in vor) + + admin.post("/api/uebertraege/ignorieren", + json={"a_id": raus["id"], "b_id": rein["id"]}) + + nach = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + assert not any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in nach) + + # Und es ist auffindbar, statt spurlos zu verschwinden. + liste = admin.get("/api/uebertraege/ignoriert").json() + assert any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in liste) + + r = admin.request("DELETE", "/api/uebertraege/ignoriert", + json={"a_id": raus["id"], "b_id": rein["id"]}) + assert r.status_code == 204, r.text + + wieder = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + assert any({p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]} for p in wieder) + + +def test_ignoriert_zeigt_genug_zum_wiedererkennen(admin): + raus, rein = _paar(admin, "Lesbar") + admin.post("/api/uebertraege/ignorieren", + json={"a_id": raus["id"], "b_id": rein["id"]}) + + eintrag = [p for p in admin.get("/api/uebertraege/ignoriert").json() + if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}][0] + for seite in (eintrag["a"], eintrag["b"]): + assert seite["date"] == "2026-05-04" + assert seite["amount_cents"] == 777 + assert seite["payee"] == "Umbuchung X" + assert seite["account_name"].startswith("Lesbar") + + +def test_geloeschte_buchung_verschwindet_aus_der_liste(admin): + """Ein Eintrag, zu dem es nichts mehr zurueckzunehmen gibt, ist Rauschen.""" + raus, rein = _paar(admin, "Geloescht") + admin.post("/api/uebertraege/ignorieren", + json={"a_id": raus["id"], "b_id": rein["id"]}) + admin.delete(f"/api/transactions/{rein['id']}") + + liste = admin.get("/api/uebertraege/ignoriert").json() + assert not any(p["a"]["id"] == raus["id"] or p["b"]["id"] == raus["id"] + for p in liste) + + +def test_fremdes_paar_laesst_sich_nicht_zurueckholen(admin): + assert date.today() + r = admin.request("DELETE", "/api/uebertraege/ignoriert", + json={"a_id": 999999, "b_id": 999998}) + assert r.status_code == 404, r.text diff --git a/web/src/api.js b/web/src/api.js index 13a3371..ef2f86e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -250,6 +250,11 @@ export const api = { request(`/uebertraege/${linkId}`, { method: "DELETE" }), ignoreTransfer: (a_id, b_id) => request("/uebertraege/ignorieren", { method: "POST", body: { a_id, b_id } }), + // Abgelehnte Vorschlaege ansehen und zuruecknehmen. Ohne das waere + // „Ignorieren" eine Einbahnstrasse. + ignoredTransfers: () => request("/uebertraege/ignoriert"), + unignoreTransfer: (a_id, b_id) => + request("/uebertraege/ignoriert", { method: "DELETE", body: { a_id, b_id } }), listBankConnections: () => request("/bank/connections"), syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }), diff --git a/web/src/components/Uebertragsvorschlaege.jsx b/web/src/components/Uebertragsvorschlaege.jsx index a25c97c..9e09387 100644 --- a/web/src/components/Uebertragsvorschlaege.jsx +++ b/web/src/components/Uebertragsvorschlaege.jsx @@ -18,6 +18,11 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { const [stand, setStand] = useState(null); // { verknuepft, vorschlaege } const [busy, setBusy] = useState(false); + // Abgelehnte Vorschlaege. Ohne diese Liste war „Ignorieren" endgueltig: + // Das Paar blieb bei jedem weiteren Lauf stumm, und nichts verriet, dass es + // je einen Vorschlag gab – wer einmal falsch klickte, suchte danach einen + // Fehler in der Erkennung, den es nicht gab. + const [abgelehnt, setAbgelehnt] = useState([]); async function suchen(melden = false) { setBusy(true); @@ -37,7 +42,21 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { } } - useEffect(() => { suchen(false); }, []); + async function abgelehnteLaden() { + try { setAbgelehnt(await api.ignoredTransfers()); } catch { /* still */ } + } + + useEffect(() => { suchen(false); abgelehnteLaden(); }, []); + + async function zurueckholen(paar) { + try { + await api.unignoreTransfer(paar.a.id, paar.b.id); + await abgelehnteLaden(); + suchen(false); + } catch (e) { + toast(e.message, "error"); + } + } async function verbinden(paar) { try { @@ -53,6 +72,7 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { try { await api.ignoreTransfer(paar.a.id, paar.b.id); suchen(false); + abgelehnteLaden(); } catch (e) { toast(e.message, "error"); } @@ -60,7 +80,7 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { // Vor dem ersten Ergebnis keine leere Karte zeigen. if (stand === null) return null; - if (stand.vorschlaege.length === 0) return null; + if (stand.vorschlaege.length === 0 && abgelehnt.length === 0) return null; return (
@@ -102,6 +122,30 @@ export default function Uebertragsvorschlaege({ baseCurrency, onChange }) { ); })}
+ + {/* Der Rueckweg. Eingeklappt, weil man ihn selten braucht – aber da, + weil ein Klick sonst fuer immer gilt. */} + {abgelehnt.length > 0 && ( +
+ + {t("transfers.ignoredCount", { count: abgelehnt.length })} + +

{t("transfers.ignoredHint")}

+ {abgelehnt.map((paar) => ( +
+
+ + + +
+ +
+ ))} +
+ )} ); } diff --git a/web/src/finance.css b/web/src/finance.css index e75384a..74aa48a 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1208,6 +1208,24 @@ tr.row-dup { font-size: 0.9em; } +/* Eigene Notiz an einer Buchung – von Hand geschrieben und deshalb + aussagekraeftiger als jeder Banktext. Abgesetzt, damit sie nicht mit dem + Verwendungszweck verwechselt wird. */ +.buchungsnotiz { + color: var(--muted); + font-style: italic; + opacity: 0.95; +} +.buchungsnotiz svg { vertical-align: -1px; } + +/* Der Rueckweg aus „Ignorieren" – selten gebraucht, aber vorhanden. */ +.abgelehnte { + margin-top: var(--sp-3); + border-top: 1px solid var(--border); + padding-top: var(--sp-2); +} +.abgelehnte summary { cursor: pointer; padding: 4px 0; } + /* ---------- Übertrags-Vorschläge ---------- */ .uebertrag-vorschlag { padding: var(--sp-3) 0; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 2e3b8e7..815d9ae 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -295,6 +295,8 @@ "toAmount": "Gutschrift", "payee": "Empfänger", "description": "Verwendungszweck", + "notes": "Notiz", + "notesPlaceholder": "Wofür war das? z. B. „Geschenk“, „beruflich“, „Werkzeug“", "uncategorized": "Ohne Kategorie", "empty": "Noch keine Buchungen.", "needAccount": "Lege zuerst ein Konto an, um Buchungen zu erfassen.", @@ -807,6 +809,12 @@ "ignore": "Ignorieren", "hasCategory": "Eine der beiden hat eine Kategorie – sie fällt durchs Verbinden aus der Auswertung.", "linked": "Übertrag", + "linkedHint": "Übertrag zwischen eigenen Konten – zählt weder als Ausgabe noch als Einnahme.", + "suggestionsFailed": "Mögliche Überträge konnten nicht geladen werden: {{fehler}}", + "ignoredCount_one": "{{count}} abgelehnter Vorschlag", + "ignoredCount_other": "{{count}} abgelehnte Vorschläge", + "ignoredHint": "Abgelehnte Paare bleiben bei jedem weiteren Suchlauf stumm. Zurückholen heisst: Sie werden wieder vorgeschlagen.", + "unignore": "Wieder vorschlagen", "release": "Verknüpfung lösen", "releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme.", "question": "Übertrag von/nach {{konto}}?" diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3e6660c..e7c1216 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -295,6 +295,8 @@ "toAmount": "Credited", "payee": "Payee", "description": "Description", + "notes": "Note", + "notesPlaceholder": "What was it for? e.g. “gift”, “work”, “tools”", "uncategorized": "Uncategorized", "empty": "No transactions yet.", "needAccount": "Create an account first to record transactions.", @@ -807,6 +809,12 @@ "ignore": "Ignore", "hasCategory": "One of them has a category – connecting takes it out of the evaluation.", "linked": "Transfer", + "linkedHint": "Transfer between your own accounts — counts neither as an expense nor as income.", + "suggestionsFailed": "Possible transfers could not be loaded: {{fehler}}", + "ignoredCount_one": "{{count}} dismissed suggestion", + "ignoredCount_other": "{{count}} dismissed suggestions", + "ignoredHint": "Dismissed pairs stay silent in every later search. Bringing one back means it will be suggested again.", + "unignore": "Suggest again", "release": "Release link", "releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards.", "question": "Transfer from/to {{konto}}?" diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index 1d1cadf..0949e52 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -31,6 +31,7 @@ function emptyForm() { to_amount: "", payee: "", description: "", + notes: "", budget_as_expense: true, budget_category_id: "", // Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es. @@ -83,7 +84,12 @@ export default function Transactions() { api.transactionLinks().catch(() => ({})), // Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts // verknüpft werden. - api.transferSuggestions().catch(() => ({ vorschlaege: [] })), + api.transferSuggestions().catch((e) => { + // Stillschweigen waere hier das Schlimmste: Die Liste saehe aus, als + // gaebe es nichts zu verbinden. + toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error"); + return { vorschlaege: [] }; + }), ]); setTxns(liste); setLinks(verkn); @@ -211,6 +217,7 @@ export default function Transactions() { to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "", payee: tx.payee || "", description: tx.description || "", + notes: tx.notes || "", budget_as_expense: tx.budget_treatment !== "neutral", budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "", // Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst: @@ -238,6 +245,7 @@ export default function Transactions() { amount_cents: cents, payee: form.payee.trim() || null, description: form.description.trim() || null, + notes: form.notes.trim() || null, budget_period: form.budget_period || null, }; if (form.type === "transfer") { @@ -295,11 +303,31 @@ export default function Transactions() { const von = accountById[tx.account_id]?.name || "?"; const nach = accountById[gegen.account_id]?.name || "?"; return { - icon: "arrow-left-right", color: "#6b7480", + icon: "arrow-left-right", color: "#6b7480", uebertrag: true, title: `${von} → ${nach}`, sub: tx.payee || tx.description || t("transactions.transfer"), }; } + // Verknuepft, aber die Gegenseite ist nicht in der Liste (gefiltert oder + // auf einer anderen Seite). Dieselbe Darstellung wie beim Paar: Oben steht, + // zwischen WELCHEN Konten das Geld lief, und nicht ein Empfaengertext, den + // die Bank vergeben hat. + // + // Vorher stand hier der Rohtext als Titel und die Verknuepfung nur klein + // darunter – „To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c" sah aus wie + // eine Zahlung an jemanden. + const link = links[String(tx.id)]; + if (link) { + const eigen = accountById[tx.account_id]?.name || "?"; + const anderes = accountById[link.other_account_id]?.name + || t("transfers.linked"); + return { + icon: "arrow-left-right", color: "#6b7480", uebertrag: true, + title: link.richtung === "raus" + ? `${eigen} → ${anderes}` : `${anderes} → ${eigen}`, + sub: tx.payee || tx.description || "", + }; + } if (tx.type === "transfer") { const from = accountById[tx.account_id]?.name || "?"; const to = accountById[tx.to_account_id]?.name || "?"; @@ -330,7 +358,9 @@ export default function Transactions() { { // Wofür die Zahlung war – danach sucht man in der Liste. key: "text", header: t("transactions.subject"), grow: true, min: 190, - filterText: (tx) => rowLabel(tx).title, + // Auch die Notiz durchsuchbar: Wer sie schreibt, will spaeter danach + // suchen koennen („Geschenk", „beruflich"). + filterText: (tx) => [rowLabel(tx).title, tx.notes].filter(Boolean).join(" "), sortValue: (tx) => rowLabel(tx).title, render: (tx) => { const l = rowLabel(tx); @@ -343,6 +373,15 @@ export default function Transactions() { stapeln macht die Zeilen hoch und den Blick eng. */}
{l.title} + {/* Ein Uebertrag ist keine Ausgabe und keine Einnahme. Das + gehoert in die erste Zeile, nicht in eine Fussnote. */} + {l.uebertrag && ( + + + {t("transactions.transfer")} + + )} {t(`todo.from.${tx.herkunft}`, { defaultValue: tx.herkunft, })} @@ -358,17 +397,16 @@ export default function Transactions() { )}
{l.sub &&
{l.sub}
} - {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. - Ohne das Gegenkonto sagt „Übertrag" nichts. */} - {/* 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 - || t("transfers.linked")} + {/* Von Hand geschrieben und deshalb aussagekraeftiger als jeder + Banktext: „Amazon" kann Einrichtung sein, Werkzeug oder ein + Geschenk. */} + {tx.notes && ( +
+ {tx.notes}
)} + {/* Die fruehere Marke mit dem Gegenkonto ist entfallen: Das + Gegenkonto steht jetzt im Titel, wo man es sucht. */} {/* Nachträglich erkannt: Auf einem anderen Konto steht der gleiche Betrag am selben Tag in die andere Richtung. Fragen statt tun – und die Antwort merken. */} @@ -657,6 +695,16 @@ export default function Transactions() { {t("transactions.description")} setForm({ ...form, description: e.target.value })} /> + {/* Die Kategorie sagt, in welche Schublade es gehoert. Die Notiz + sagt, WAS es war – „Amazon" kann Einrichtung sein, Werkzeug + oder ein Geschenk, und in drei Monaten weiss das niemand mehr. + Freier Text, weil sich das nicht in Schubladen fassen laesst. */} +