+ );
+}
diff --git a/web/src/finance.css b/web/src/finance.css
index 9e3b0a5..2cf7930 100644
--- a/web/src/finance.css
+++ b/web/src/finance.css
@@ -1153,3 +1153,44 @@ tr.row-dup {
fehlende oder doppelte Buchung – deshalb warnend, nicht rot. */
.saldo-ok { color: var(--ok); }
.saldo-abweichung { color: var(--warn); font-weight: 560; }
+
+/* ---------- Übertrags-Vorschläge ---------- */
+.uebertrag-vorschlag {
+ padding: var(--sp-3) 0;
+ border-bottom: 1px solid var(--border);
+}
+.uebertrag-vorschlag:last-child {
+ border-bottom: none;
+}
+/* Die beiden Seiten nebeneinander, damit man sie vergleicht statt sie zu
+ lesen. Auf schmalen Schirmen untereinander – gestapelt bleibt der Vergleich
+ möglich, gequetscht nicht. */
+.uebertrag-seiten {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+}
+.uebertrag-seite {
+ flex: 1 1 0;
+ min-width: 0;
+}
+.uebertrag-pfeil {
+ flex: 0 0 auto;
+ color: var(--muted);
+}
+@media (max-width: 820px) {
+ .uebertrag-seiten {
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--sp-2);
+ }
+}
+
+/* Übertrags-Marke in der Buchungsliste: gedämpft, nicht warnend – die Buchung
+ ist in Ordnung, sie zählt nur nicht als Ausgabe oder Einnahme. */
+.uebertrag-marke {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--accent);
+}
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index e8e556c..7d1271f 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -633,5 +633,17 @@
"noAccounts": "Verbunden, aber die Bank hat kein Konto übermittelt",
"noAccountsHint": "Im eingeschränkten Betrieb gibt die Bank nur Konten heraus, die du bei Enable Banking selbst verknüpft hast. Öffne dort deine Anwendung, drück „Activate by linking accounts“ und wähle diese Bank – danach hier noch einmal verbinden.",
"noAccountsDropped": "Die Bank hat {{n}} Einträge geschickt, aber ohne Kennung – damit lässt sich nichts abrufen. Das ist ein Fall für den Support von Enable Banking; schick mir die Meldung, dann formuliere ich die Anfrage."
+ },
+ "transfers": {
+ "title": "Mögliche Überträge",
+ "intro": "Ein Abgang hier, ein Zugang dort – vermutlich dasselbe Geld. Verbinden ändert keine Buchung: Beide bleiben stehen und zählen nur nicht mehr als Ausgabe und Einnahme. Lösen stellt alles wieder her.",
+ "search": "Neu suchen",
+ "searched": "{{verknuepft}} verbunden, {{offen}} zum Ansehen.",
+ "connect": "Verbinden",
+ "ignore": "Ignorieren",
+ "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."
}
}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index c508441..fdd4d83 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -633,5 +633,17 @@
"noAccounts": "Connected, but the bank returned no account",
"noAccountsHint": "In restricted mode the bank only releases accounts you linked at Enable Banking yourself. Open your application there, press “Activate by linking accounts” and pick this bank – then connect again here.",
"noAccountsDropped": "The bank sent {{n}} entries but without an identifier, so nothing can be fetched. That is one for Enable Banking support; send me the message and I will draft the question."
+ },
+ "transfers": {
+ "title": "Possible transfers",
+ "intro": "Money out here, money in there – probably the same money. Connecting changes no transaction: both stay put and simply stop counting as expense and income. Releasing restores everything.",
+ "search": "Search again",
+ "searched": "{{verknuepft}} connected, {{offen}} to review.",
+ "connect": "Connect",
+ "ignore": "Ignore",
+ "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."
}
}
diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx
index 00a3388..98792e9 100644
--- a/web/src/pages/Eingang.jsx
+++ b/web/src/pages/Eingang.jsx
@@ -6,6 +6,7 @@ import CategorySelect from "../components/CategorySelect";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
+import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
import { useToast } from "../toast";
import { useConfirm } from "../confirm";
import { useSettings } from "../settings";
@@ -102,6 +103,10 @@ export default function Eingang() {
)}
+ {/* Nach dem Buchen entsteht die Gegenseite eines Übertrags erst –
+ deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
+
+
{rows.length === 0 ? (
{t("inbox.empty")}
) : (
diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx
index b1d4570..a36e3e4 100644
--- a/web/src/pages/Transactions.jsx
+++ b/web/src/pages/Transactions.jsx
@@ -41,6 +41,8 @@ export default function Transactions() {
const toast = useToast();
const [txns, setTxns] = useState([]);
+ // Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
+ const [links, setLinks] = useState({});
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [filter, setFilter] = useState({ account_id: "", type: "" });
@@ -66,7 +68,30 @@ export default function Transactions() {
}
async function ladenBuchungen() {
try {
- setTxns(await api.listTransactions({ account_id: filter.account_id, type: filter.type }));
+ // 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([
+ api.listTransactions({ account_id: filter.account_id, type: filter.type }),
+ api.transactionLinks().catch(() => ({})),
+ ]);
+ setTxns(liste);
+ setLinks(verkn);
+ } catch (err) {
+ toast(err.message, "error");
+ }
+ }
+
+ async function loesen(tx) {
+ const link = links[String(tx.id)];
+ if (!link) return;
+ if (!(await confirm({
+ title: t("transfers.release"),
+ message: t("transfers.releaseConfirm"),
+ confirmLabel: t("transfers.release"),
+ }))) return;
+ try {
+ await api.releaseTransfer(link.link_id);
+ ladenBuchungen();
} catch (err) {
toast(err.message, "error");
}
@@ -218,6 +243,15 @@ export default function Transactions() {
{l.title}
{l.sub &&
{l.sub}
}
+ {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme.
+ Ohne das Gegenkonto sagt „Übertrag" nichts. */}
+ {links[String(tx.id)] && (
+