feat: Buchungen lassen sich von Hand einem Abo zuordnen – und das merkt sich
Die Erkennung lief seit Stufe 3, aber nur automatisch: Was sie nicht sicher traf, blieb liegen, und sehen konnte man davon nichts. Die Endpunkte gab es, die Oberflaeche kannte keinen davon. Jetzt steht in der Buchungsliste eine Spalte "Abo" – dieselbe Form wie die Kategorie daneben: Die Auswahl zeigt, zu welchem Vertrag die Zahlung gehoert, aendert es, und leer loest die Zuordnung. Uebertraege bleiben aussen vor; sie verschieben nur Geld. Und sie lernt. Musste man von Hand ran, hat der Erkennungstext nicht gepasst – also wird der Empfaenger dieser Buchung der neue. Beim naechsten Mal faellt dieselbe Zahlung von selbst zu, statt dass man jeden Monat dasselbe klickt. Der Vertragsname bleibt als zweite Chance: _passt_text prueft beide, nicht entweder/oder, sonst verlaere man beim Lernen, was vorher schon getroffen hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
493a98ab6b
commit
0ea2ab1322
@@ -286,6 +286,12 @@ export const api = {
|
||||
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
||||
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
||||
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
|
||||
// Zuordnung Buchung -> Vertrag. Beim Zuordnen von Hand lernt der Vertrag
|
||||
// den Empfaengertext der Buchung.
|
||||
assignSubscription: (subId, txnId) =>
|
||||
request(`/subscriptions/${subId}/buchungen/${txnId}`, { method: "POST" }),
|
||||
unassignSubscription: (txnId) =>
|
||||
request(`/subscriptions/buchungen/${txnId}`, { method: "DELETE" }),
|
||||
// Das Logo geht als Datei raus, nicht als JSON – deshalb FormData statt body.
|
||||
uploadSubscriptionLogo: (id, datei) => {
|
||||
const fd = new FormData();
|
||||
|
||||
@@ -357,6 +357,8 @@
|
||||
},
|
||||
"abos": {
|
||||
"title": "Abos & Verträge",
|
||||
"noneAssigned": "— kein Abo —",
|
||||
"column": "Abo",
|
||||
"new": "Neuer Vertrag",
|
||||
"edit": "Vertrag bearbeiten",
|
||||
"delete": "Löschen",
|
||||
|
||||
@@ -357,6 +357,8 @@
|
||||
},
|
||||
"abos": {
|
||||
"title": "Subscriptions & contracts",
|
||||
"noneAssigned": "— no subscription —",
|
||||
"column": "Subscription",
|
||||
"new": "New contract",
|
||||
"edit": "Edit contract",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function Transactions() {
|
||||
const [links, setLinks] = useState({});
|
||||
// Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt.
|
||||
const [vorschlag, setVorschlag] = useState({});
|
||||
const [abos, setAbos] = useState([]);
|
||||
// Buchung, die gerade zum Übertrag gemacht wird.
|
||||
const [uebertrag, setUebertrag] = useState(null);
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
@@ -79,7 +80,7 @@ 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, vorschlaege] = await Promise.all([
|
||||
const [liste, verkn, vorschlaege, aboListe] = 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
|
||||
@@ -90,9 +91,11 @@ export default function Transactions() {
|
||||
toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error");
|
||||
return { vorschlaege: [] };
|
||||
}),
|
||||
api.listSubscriptions().catch(() => ({ subscriptions: [] })),
|
||||
]);
|
||||
setTxns(liste);
|
||||
setLinks(verkn);
|
||||
setAbos(aboListe.subscriptions || []);
|
||||
const paare = {};
|
||||
for (const v of vorschlaege.vorschlaege || []) {
|
||||
paare[v.a.id] = v.b;
|
||||
@@ -139,6 +142,19 @@ export default function Transactions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Zuordnen heisst zugleich: lernen. Musste man von Hand ran, hat der
|
||||
// Erkennungstext des Vertrags nicht gepasst; der Server uebernimmt den
|
||||
// Empfaenger dieser Buchung, damit es beim naechsten Mal von selbst faellt.
|
||||
async function aboSetzen(tx, subId) {
|
||||
try {
|
||||
if (subId) await api.assignSubscription(subId, tx.id);
|
||||
else await api.unassignSubscription(tx.id);
|
||||
ladenBuchungen();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loesen(tx) {
|
||||
const link = links[String(tx.id)];
|
||||
if (!link) return;
|
||||
@@ -455,6 +471,24 @@ export default function Transactions() {
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
// Welcher Vertrag diese Zahlung ist. Dieselbe Form wie die Kategorie
|
||||
// daneben: Die Auswahl zeigt den Stand und aendert ihn, leer loest.
|
||||
key: "abo", header: t("abos.column"), width: 160,
|
||||
filterText: (tx) => abos.find((a) => a.id === tx.subscription_id)?.name || "",
|
||||
render: (tx) => (
|
||||
// Ein Uebertrag ist keine Abo-Zahlung – er verschiebt nur Geld.
|
||||
tx.type === "transfer" || links[String(tx.id)] ? (
|
||||
<span className="muted">—</span>
|
||||
) : (
|
||||
<select value={tx.subscription_id || ""}
|
||||
onChange={(e) => aboSetzen(tx, e.target.value ? Number(e.target.value) : null)}>
|
||||
<option value="">{t("abos.noneAssigned")}</option>
|
||||
{abos.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "account", header: t("transactions.account"), width: 130,
|
||||
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
||||
@@ -532,7 +566,7 @@ export default function Transactions() {
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
||||
formatDate, links, paarZu, vorschlag, t]);
|
||||
formatDate, links, paarZu, vorschlag, abos, t]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user