feat: Certain fragt in der Buchungsliste nach, ob es ein Uebertrag ist

Die Erkennung gab es laengst – aber nur auf der Seite "Zu erledigen", in einer
Karte, die man erst finden muss. Wer in der Buchungsliste steht und die zweite
Haelfte eines Uebertrags sieht, greift stattdessen zum ↔-Knopf und legt einen
zweiten Uebertrag an. Genau so ist heute eine Zahlung doppelt verschoben
worden.

Jetzt steht die Frage in der Zeile: "Uebertrag von/nach Revolut CHF?" mit
Verbinden und Ignorieren. Ignorieren wird gemerkt, sonst fragte Certain bei
jedem Laden erneut.

Dafuer gibt es die Suche jetzt auch als reines Nachschlagen: ``finde`` mit
``nur_vorschlaege`` verknuepft nichts, und dahinter liegt GET
/uebertraege/vorschlaege. Eine Ansicht, die bloss fragt, darf beim Hinsehen
nichts veraendern – sonst entschiede das Anschauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 13:03:25 +02:00
co-authored by Claude Opus 5
parent 7b3ed3e63c
commit bb748cc092
8 changed files with 128 additions and 7 deletions
+3
View File
@@ -229,6 +229,9 @@ export const api = {
applyRules: (vorschau = true) =>
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
transactionLinks: () => request("/transactions/links"),
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
// dass hier ein Uebertrag stecken koennte.
transferSuggestions: () => request("/uebertraege/vorschlaege"),
searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }),
connectTransfer: (a_id, b_id) =>
request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }),
+16
View File
@@ -1391,3 +1391,19 @@ tr.row-dup {
.bank-verbindung .input-with-button > .muted {
white-space: nowrap;
}
/* Rückfrage in der Buchungszeile: „Das könnte ein Übertrag sein." Bewusst
auffällig genug, um gesehen zu werden – sie verlangt eine Antwort, anders
als die Marke einer schon bestätigten Verknüpfung. */
.uebertrag-frage {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-top: 3px;
font-size: 0.8rem;
color: var(--warn);
min-width: 0;
}
.uebertrag-frage .btn {
flex: 0 0 auto;
}
+2 -1
View File
@@ -683,7 +683,8 @@
"hasCategory": "Eine der beiden hat eine Kategorie – sie fällt durchs Verbinden aus der Auswertung.",
"linked": "Übertrag",
"release": "Verknüpfung lösen",
"releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme."
"releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme.",
"question": "Übertrag von/nach {{konto}}?"
},
"todo": {
"title": "Zu erledigen",
+2 -1
View File
@@ -683,7 +683,8 @@
"hasCategory": "One of them has a category – connecting takes it out of the evaluation.",
"linked": "Transfer",
"release": "Release link",
"releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards."
"releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards.",
"question": "Transfer from/to {{konto}}?"
},
"todo": {
"title": "To do",
+57 -2
View File
@@ -44,6 +44,8 @@ export default function Transactions() {
const [txns, setTxns] = useState([]);
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
const [links, setLinks] = useState({});
// Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt.
const [vorschlag, setVorschlag] = useState({});
// Buchung, die gerade zum Übertrag gemacht wird.
const [uebertrag, setUebertrag] = useState(null);
const [accounts, setAccounts] = useState([]);
@@ -73,12 +75,21 @@ export default function Transactions() {
try {
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
// kein Feld an ihnen – deshalb auch ein eigener Abruf.
const [liste, verkn] = await Promise.all([
const [liste, verkn, vorschlaege] = await Promise.all([
api.listTransactions({ account_id: filter.account_id, type: filter.type }),
api.transactionLinks().catch(() => ({})),
// Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts
// verknüpft werden.
api.transferSuggestions().catch(() => ({ vorschlaege: [] })),
]);
setTxns(liste);
setLinks(verkn);
const paare = {};
for (const v of vorschlaege.vorschlaege || []) {
paare[v.a.id] = v.b;
paare[v.b.id] = v.a;
}
setVorschlag(paare);
} catch (err) {
toast(err.message, "error");
}
@@ -96,6 +107,29 @@ export default function Transactions() {
}
}
async function verbinden(tx) {
const gegen = vorschlag[tx.id];
if (!gegen) return;
try {
await api.connectTransfer(tx.id, gegen.id);
ladenBuchungen();
} catch (err) {
toast(err.message, "error");
}
}
async function nichtVerbinden(tx) {
const gegen = vorschlag[tx.id];
if (!gegen) return;
try {
// Ablehnen wird gemerkt, sonst fragt Certain bei jedem Laden erneut.
await api.ignoreTransfer(tx.id, gegen.id);
ladenBuchungen();
} catch (err) {
toast(err.message, "error");
}
}
async function loesen(tx) {
const link = links[String(tx.id)];
if (!link) return;
@@ -280,6 +314,27 @@ export default function Transactions() {
|| t("transfers.linked")}
</div>
)}
{/* 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. */}
{!links[String(tx.id)] && vorschlag[tx.id] && (
<div className="uebertrag-frage">
<Icon name="arrow-left-right" size={12} />
<span className="ellipsis">
{t("transfers.question", {
konto: accountById[vorschlag[tx.id].account_id]?.name || "?",
})}
</span>
<button type="button" className="btn sm"
onClick={() => verbinden(tx)}>
{t("transfers.connect")}
</button>
<button type="button" className="btn sm ghost"
onClick={() => nichtVerbinden(tx)}>
{t("transfers.ignore")}
</button>
</div>
)}
</div>
</div>
);
@@ -356,7 +411,7 @@ export default function Transactions() {
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
formatDate, links, t]);
formatDate, links, vorschlag, t]);
return (
<div>