diff --git a/backend/app/main.py b/backend/app/main.py index 771e8dd..0f05d35 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -161,6 +161,20 @@ if settings.cors_origins.strip(): allow_headers=["*"], ) +@app.middleware("http") +async def keine_zwischenspeicherung(request, call_next): + """API-Antworten nie zwischenspeichern. + + Ohne Cache-Control darf ein Browser selbst entscheiden, und der Zurueck- + Knopf zeigt dann Salden von vorhin. Bei Geld ist eine veraltete Anzeige + schlimmer als eine langsame. + """ + antwort = await call_next(request) + if request.url.path.startswith("/api"): + antwort.headers["Cache-Control"] = "no-store" + return antwort + + # --- API ------------------------------------------------------------------- # Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt: # Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 68396ec..7b48061 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -45,6 +45,7 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s category_id=txn.category_id, budget_treatment=txn.budget_treatment, budget_category_id=txn.budget_category_id, + import_batch_id=txn.import_batch_id, payee=txn.payee, description=txn.description, notes=txn.notes, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a19c93c..a602476 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -323,6 +323,9 @@ class TransactionOut(BaseModel): category_id: int | None budget_treatment: BudgetTreatment | None budget_category_id: int | None + # Woher die Buchung stammt, fuer die Arbeitsliste: Ist sie gesetzt, + # kam die Buchung aus einem Datei-Import. + import_batch_id: int | None = None payee: str | None description: str | None notes: str | None diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py index cb2bca5..145e8d6 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -342,3 +342,12 @@ def test_uebernehmen_ohne_bank_saldo_wird_erklaert(admin): r = admin.post(f"/api/accounts/{konto['id']}/saldo-uebernehmen") assert r.status_code == 400 assert "abrufen" in r.json()["detail"] + + +def test_api_antworten_werden_nicht_zwischengespeichert(admin): + """Ohne das darf ein Browser selbst entscheiden – und der Zurueck-Knopf + zeigt Salden von vorhin.""" + r = admin.get("/api/inbox") + assert r.headers.get("cache-control") == "no-store" + r = admin.get("/api/accounts") + assert r.headers.get("cache-control") == "no-store" diff --git a/web/src/App.jsx b/web/src/App.jsx index 6d16958..bc999ea 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -14,7 +14,6 @@ import Budget from "./pages/Budget"; import Import from "./pages/Import"; import BankRueckkehr from "./pages/BankRueckkehr"; import Eingang from "./pages/Eingang"; -import Offen from "./pages/Offen"; import Users from "./pages/Users"; import Settings from "./pages/Settings"; import TwoFactor from "./pages/TwoFactor"; @@ -51,8 +50,7 @@ function Sidebar() { - - +
{t("nav.system")}
@@ -139,7 +137,9 @@ export default function App() { } /> } /> } /> - } /> + {/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen, + stand jede Bank-Buchung ohne Kategorie in beiden Listen. */} + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 17ed8e7..8a981d2 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -215,6 +215,11 @@ export const api = { // --- Überträge zwischen eigenen Konten ----------------------------------- // Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird // angefasst – deshalb ist auch eine falsche Verknüpfung folgenlos. + // Eine gebuchte Ausgabe oder Einnahme nachtraeglich zum Uebertrag machen. + makeTransactionTransfer: (id, toAccountId, trotzdem = false) => + request(`/transactions/${id}/transfer`, { + method: "POST", body: { to_account_id: toAccountId, trotzdem }, + }), transactionLinks: () => request("/transactions/links"), searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }), connectTransfer: (a_id, b_id) => diff --git a/web/src/finance.css b/web/src/finance.css index 059607c..7759517 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1307,3 +1307,19 @@ tr.row-dup { scroll-margin-top: var(--sp-3); } } + +/* Kategorie-Auswahl in einer Tabellenzeile. Sie steht dort, wo bisher nur der + Name der Kategorie stand – also klein und gedeckelt, nicht über die ganze + (wachsende) Spalte. */ +.zeilen-kategorie { + max-width: 240px; + margin-top: 2px; +} +.zeilen-kategorie .catsel-btn { + margin-top: 0; + padding: 3px 6px; + font-size: 0.8rem; +} +.zeilen-kategorie .catsel-value { + font-weight: 500; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 32b19c8..2a06b91 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -23,8 +23,7 @@ "system": "System", "users": "Benutzer", "settings": "Einstellungen", - "open": "Offene Buchungen", - "inbox": "Eingang" + "todo": "Zu erledigen" }, "login": { "subtitle": "Bitte anmelden", @@ -663,5 +662,18 @@ "linked": "Übertrag", "release": "Verknüpfung lösen", "releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme." + }, + "todo": { + "title": "Zu erledigen", + "subtitle": "{{n}} offen", + "allDone": "Nichts offen – alles zugeordnet und gebucht.", + "from": { + "bank": "Bank", + "mail": "Mail", + "shortcut": "Kurzbefehl", + "transfer": "Übertrag", + "csv": "Datei-Import", + "manuell": "von Hand" + } } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c96e580..be5cf6d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -23,8 +23,7 @@ "system": "System", "users": "Users", "settings": "Settings", - "open": "Open bookings", - "inbox": "Captured" + "todo": "To do" }, "login": { "subtitle": "Please sign in", @@ -663,5 +662,18 @@ "linked": "Transfer", "release": "Release link", "releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards." + }, + "todo": { + "title": "To do", + "subtitle": "{{n}} open", + "allDone": "Nothing open – everything categorised and booked.", + "from": { + "bank": "Bank", + "mail": "Mail", + "shortcut": "Shortcut", + "transfer": "Transfer", + "csv": "File import", + "manuell": "manual" + } } } diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx index f482e57..96f8696 100644 --- a/web/src/pages/Eingang.jsx +++ b/web/src/pages/Eingang.jsx @@ -2,10 +2,10 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; +import AccountSelect from "../components/AccountSelect"; import CategorySelect from "../components/CategorySelect"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; -import AccountSelect from "../components/AccountSelect"; import Modal from "../components/Modal"; import Money from "../components/Money"; import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege"; @@ -15,19 +15,31 @@ import { useSettings } from "../settings"; import { gruppenFuer } from "../kategorien"; import { formatDate } from "../format"; -// Was unterwegs erfasst wurde – per Kurzbefehl vom Telefon. Die Einträge bleiben -// hier stehen, auch nachdem sie gebucht sind: Der spätere Kontoauszug gleicht -// gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal in den Saldo läuft. +// Die Arbeitsliste: alles, was noch eine Hand braucht – aus welcher Quelle auch +// immer. Zwei Arten von Zeilen: +// +// Vorschlag – kam per Mail oder Kurzbefehl und ist noch nicht gebucht. +// Buchung – ist gebucht, hat aber keine Kategorie. +// +// Das war früher auf zwei Seiten verteilt („Eingang" und „Offene Buchungen"). +// Seit angebundene Konten selbst buchen, stand jede Bank-Buchung ohne Kategorie +// in BEIDEN – die Trennung stammte aus der Zeit, als der Eingang nur Vorschläge +// enthielt. +// +// Erledigtes verschwindet aus der Liste. Die Eingangs-Einträge bleiben in der +// Datenbank: Der spätere CSV-Import gleicht gegen sie ab, damit dieselbe +// Zahlung nicht ein zweites Mal verbucht wird. export default function Eingang() { const { t } = useTranslation(); const { toast } = useToast(); const confirm = useConfirm(); const { baseCurrency } = useSettings(); - const [rows, setRows] = useState(null); + const [inbox, setInbox] = useState(null); + const [buchungen, setBuchungen] = useState([]); const [accounts, setAccounts] = useState([]); const [groups, setGroups] = useState([]); - // Vorschlag, der gerade als Übertrag gebucht wird, samt Zielkonto. + const [merken, setMerken] = useState(true); const [uebertrag, setUebertrag] = useState(null); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); @@ -37,10 +49,14 @@ export default function Eingang() { async function laden() { try { - const [items, acc, cat] = await Promise.all([ - api.listInbox(), api.listAccounts(), api.listCategories(), + const [items, tx, acc, cat] = await Promise.all([ + api.listInbox(), + api.listTransactions({ uncategorized: true }), + api.listAccounts(), + api.listCategories(), ]); - setRows(items); + setInbox(items); + setBuchungen(tx); setAccounts(acc); setGroups(cat); } catch (e) { @@ -49,22 +65,69 @@ export default function Eingang() { } useEffect(() => { laden(); }, []); - async function zuordnen(item, categoryId) { + // Woher eine Buchung stammt. Die gebuchten Eingangs-Einträge sagen es für + // alles, was über Bank, Mail oder Kurzbefehl kam – dafür braucht es keine + // zweite Abfrage, die Liste ist ohnehin schon da. + const herkunft = useMemo(() => { + const nach = {}; + for (const e of inbox || []) { + if (e.transaction_id != null) nach[e.transaction_id] = e.source; + } + return nach; + }, [inbox]); + + // Beides in EINER Liste, älteste zuerst – so sieht man den Stapel kleiner + // werden, statt zwischen zwei Seiten zu wechseln. + const aufgaben = useMemo(() => { + const offen = (inbox || []) + .filter((e) => e.status === "pending" || e.status === "unclear") + .map((e) => ({ art: "vorschlag", schluessel: `v${e.id}`, datum: e.date, e })); + const ohneKategorie = buchungen.map((tx) => ({ + art: "buchung", schluessel: `b${tx.id}`, datum: tx.date, tx, + quelle: herkunft[tx.id] || (tx.import_batch_id != null ? "csv" : "manuell"), + })); + return [...offen, ...ohneKategorie] + .sort((a, b) => a.datum.localeCompare(b.datum) || a.schluessel.localeCompare(b.schluessel)); + }, [inbox, buchungen, herkunft]); + + const offeneVorschlaege = aufgaben.filter( + (a) => a.art === "vorschlag" && a.e.account_id); + + async function zuordnenVorschlag(e, categoryId) { if (categoryId == null) return; try { - await api.setInboxCategory(item.id, categoryId); - setRows((r) => r.map((x) => (x.id === item.id ? { ...x, category_id: categoryId } : x))); - } catch (e) { - toast(e.message, "error"); + const neu = await api.setInboxCategory(e.id, categoryId); + setInbox((r) => r.map((x) => (x.id === e.id ? neu : x))); + } catch (err) { + toast(err.message, "error"); } } - async function buchen(item) { + async function zuordnenBuchung(tx, categoryId) { + if (categoryId == null) return; try { - await api.bookInboxItem(item.id); + await api.updateTransaction(tx.id, { category_id: Number(categoryId) }); + // Aus dem Text ein Schlagwort machen, damit die nächste gleiche Buchung + // schon beim Import zugeordnet wird. Das ist der Grund, warum der Stapel + // mit der Zeit kleiner wird. + const wort = (tx.payee || tx.description || "").trim(); + if (merken && wort) { + try { + await api.addKeyword(Number(categoryId), { pattern: wort, match_field: "any" }); + } catch { /* Schlagwort existiert schon oder kollidiert – nicht schlimm */ } + } + setBuchungen((r) => r.filter((x) => x.id !== tx.id)); + } catch (err) { + toast(err.message, "error"); + } + } + + async function buchen(e) { + try { + await api.bookInboxItem(e.id); laden(); - } catch (e) { - toast(e.message, "error"); + } catch (err) { + toast(err.message, "error"); } } @@ -73,39 +136,12 @@ export default function Eingang() { const r = await api.bookAllInbox(); toast(t("inbox.bookedN", { n: r.gebucht })); laden(); - } catch (e) { - toast(e.message, "error"); + } catch (err) { + toast(err.message, "error"); } } - async function uebertragBuchen() { - const { item, ziel } = uebertrag; - if (!ziel) return; - try { - await api.bookInboxAsTransfer(item.id, Number(ziel)); - } catch (e) { - // 409: Das Zielkonto haengt selbst an einer Bank und meldet den Zugang - // von allein. Die Begruendung kommt vom Server – dort steht das Wissen, - // ob ein Konto angebunden ist. - if (e.status !== 409) { toast(e.message, "error"); return; } - if (!(await confirm({ - title: t("inbox.transferTitle"), - message: e.message, - confirmLabel: t("inbox.asTransfer"), - }))) return; - try { - await api.bookInboxAsTransfer(item.id, Number(ziel), true); - } catch (zweiter) { - toast(zweiter.message, "error"); - return; - } - } - setUebertrag(null); - toast(t("inbox.transferBooked")); - laden(); - } - - async function verwerfen(item) { + async function verwerfen(e) { if (!(await confirm({ title: t("inbox.discard"), message: t("inbox.discardConfirm"), @@ -113,21 +149,85 @@ export default function Eingang() { confirmLabel: t("inbox.discard"), }))) return; try { - await api.discardInboxItem(item.id); + await api.discardInboxItem(e.id); laden(); - } catch (e) { - toast(e.message, "error"); + } catch (err) { + toast(err.message, "error"); } } - if (rows === null) return
…
; + // Der Empfängertext ist oft genau das, was man nachschlagen will („welches + // Franchise steckt hinter diesem Namen?"). Abtippen fällt damit weg. + async function kopieren(text) { + try { + await navigator.clipboard.writeText(text); + toast(t("open.copied")); + } catch { + toast(t("open.copyFailed"), "error"); + } + } + + // --- Übertrag ------------------------------------------------------------- + // Beide Arten führen durch denselben Dialog; nur der Aufruf am Ende + // unterscheidet sich. Die Frage nach dem Konto hängt an der Richtung. + + function oeffneUebertrag(aufgabe) { + const s = aufgabe.art === "vorschlag" + ? { + art: "vorschlag", id: aufgabe.e.id, kontoId: aufgabe.e.account_id, + betrag: aufgabe.e.amount_cents, waehrung: aufgabe.e.currency, + text: aufgabe.e.payee, datum: aufgabe.e.date, + } + : { + art: "buchung", id: aufgabe.tx.id, kontoId: aufgabe.tx.account_id, + betrag: aufgabe.tx.type === "income" + ? aufgabe.tx.amount_cents : -aufgabe.tx.amount_cents, + waehrung: accountById[aufgabe.tx.account_id]?.currency, + text: aufgabe.tx.payee || aufgabe.tx.description, datum: aufgabe.tx.date, + }; + setUebertrag({ ...s, ziel: "" }); + } + + async function uebertragBuchen() { + const { art, id, ziel } = uebertrag; + if (!ziel) return; + const senden = (trotzdem) => (art === "vorschlag" + ? api.bookInboxAsTransfer(id, Number(ziel), trotzdem) + : api.makeTransactionTransfer(id, Number(ziel), trotzdem)); + try { + await senden(false); + } catch (e) { + // 409 heisst nachfragen: Der Betrag der Gegenseite ist geschätzt und das + // Zielkonto meldet den Zugang selbst. Die Begründung kommt vom Server – + // dort steht das Wissen, nicht hier. + if (e.status !== 409) { toast(e.message, "error"); return; } + if (!(await confirm({ + title: t("inbox.transferTitle"), + message: e.message, + confirmLabel: t("inbox.asTransfer"), + }))) return; + try { + await senden(true); + } catch (zweiter) { + toast(zweiter.message, "error"); + return; + } + } + setUebertrag(null); + toast(t("inbox.transferBooked")); + await laden(); + } + + if (inbox === null) { + return
…
; + } return (
-

{t("inbox.title")}

-

{t("inbox.intro")}

- {rows.some((r) => r.status === "pending" && r.account_id) && ( +

{t("todo.title")}

+

{t("todo.subtitle", { n: aufgaben.length })}

+ {offeneVorschlaege.length > 0 && ( @@ -138,6 +238,44 @@ export default function Eingang() { deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */} + {aufgaben.length === 0 ? ( +
+
+ +

{t("todo.allDone")}

+
+
+ ) : ( +
+ + +
+ {aufgaben.map((aufgabe) => ( + (aufgabe.art === "vorschlag" + ? zuordnenVorschlag(aufgabe.e, id) + : zuordnenBuchung(aufgabe.tx, id))} + onBuchen={() => buchen(aufgabe.e)} + onUebertrag={() => oeffneUebertrag(aufgabe)} + onVerwerfen={() => verwerfen(aufgabe.e)} + onKopieren={kopieren} + /> + ))} +
+
+ )} + {uebertrag && ( {t("inbox.transferIntro")}

-
{uebertrag.item.payee || t("inbox.noText")}
+
{uebertrag.text || t("inbox.noText")}
- {formatDate(uebertrag.item.date)} ·{" "} - {accountById[uebertrag.item.account_id]?.name || "—"} + {formatDate(uebertrag.datum)} ·{" "} + {accountById[uebertrag.kontoId]?.name || "—"}
-
)} +
+ ); +} - {rows.length === 0 ? ( -
{t("inbox.empty")}
- ) : ( -
-
- {rows.map((item) => { - const konto = accountById[item.account_id]; - const ausgabe = item.amount_cents < 0; - return ( -
- -
-
{item.payee || t("inbox.noText")}
-
- {/* Jede Angabe für sich unzertrennlich: Auf dem Telefon - brach sonst „Revolut CHF" um oder wurde gekürzt – und - genau die Währung im Namen unterscheidet die Konten. */} - {formatDate(item.date)} - {" · "} - {konto?.name || "—"} - {item.source === "shortcut" && ` · ${t("inbox.viaShortcut")}`} - {item.source === "mail" && ` · ${t("inbox.viaMail")}`} - {item.status === "unclear" && ` · ${t("inbox.unclear")}`} - {item.status === "booked" && ` · ${t("inbox.booked")}`} -
-
- - {/* Ein Übertrag ist keine Ausgabe – er bekommt keine - Kategorie, also auch keine Auswahl dafür. */} - {item.transfer_account_id != null ? ( -
- - - {accountById[item.transfer_account_id]?.name - || t("transfers.linked")} - -
- ) : ( - zuordnen(item, id)} - /> - )} - {item.status !== "booked" && item.account_id && ( - <> - - {/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg - bliebe nur, sie als solche zu kategorisieren. */} - - - )} - -
- ); - })} -
+function Zeile({ + aufgabe, accountById, baseCurrency, expenseGroups, incomeGroups, t, + onKategorie, onBuchen, onUebertrag, onVerwerfen, onKopieren, +}) { + const vorschlag = aufgabe.art === "vorschlag"; + const e = aufgabe.e; + const tx = aufgabe.tx; + + const kontoId = vorschlag ? e.account_id : tx.account_id; + const konto = accountById[kontoId]; + const betrag = vorschlag ? e.amount_cents : tx.amount_cents; + const ausgabe = vorschlag ? betrag < 0 : tx.type !== "income"; + const text = vorschlag ? e.payee : (tx.payee || tx.description); + const uebertragsKonto = vorschlag ? e.transfer_account_id : null; + + return ( +
+ +
+
+ {text || t("inbox.noText")} + {text && ( + + )}
+
+ {/* Jede Angabe für sich unzertrennlich: Auf dem Telefon brach sonst + „Revolut CHF" um – und genau die Währung im Namen unterscheidet + die Konten. */} + {formatDate(aufgabe.datum)} + {" · "} + {konto?.name || "—"} + {" · "} + + {t(`todo.from.${vorschlag ? e.source : aufgabe.quelle}`, { + defaultValue: vorschlag ? e.source : aufgabe.quelle, + })} + + {vorschlag && e.status === "unclear" && ` · ${t("inbox.unclear")}`} +
+
+ + + + {/* Ein Übertrag ist keine Ausgabe – er bekommt keine Kategorie. */} + {uebertragsKonto != null ? ( +
+ + + {accountById[uebertragsKonto]?.name || t("transfers.linked")} + +
+ ) : ( + + )} + + {vorschlag && e.account_id && ( + + )} + {/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg bliebe nur, sie als + solche zu kategorisieren. */} + {uebertragsKonto == null && kontoId && ( + + )} + {vorschlag && ( + )}
); diff --git a/web/src/pages/Offen.jsx b/web/src/pages/Offen.jsx deleted file mode 100644 index bc2db4f..0000000 --- a/web/src/pages/Offen.jsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { api } from "../api"; -import CategorySelect from "../components/CategorySelect"; -import Icon from "../components/Icon"; -import IconChip from "../components/IconChip"; -import Money from "../components/Money"; -import { useToast } from "../toast"; -import { useSettings } from "../settings"; -import { gruppenFuer } from "../kategorien"; -import { formatDate } from "../format"; - -// Arbeitsliste: alle Buchungen ohne Kategorie, älteste zuerst. Nach dem Zuordnen -// verschwindet die Zeile – so sieht man, wie der Stapel kleiner wird, statt in -// der Gesamtliste nach Lücken zu suchen. -export default function Offen() { - const { t } = useTranslation(); - const { toast } = useToast(); - const { baseCurrency } = useSettings(); - const [rows, setRows] = useState(null); - const [accounts, setAccounts] = useState([]); - const [groups, setGroups] = useState([]); - const [merken, setMerken] = useState(true); - - const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); - const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); - const accountById = useMemo( - () => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]); - - async function laden() { - try { - const [tx, acc, cat] = await Promise.all([ - api.listTransactions({ uncategorized: true }), - api.listAccounts(), - api.listCategories(), - ]); - setRows([...tx].sort((a, b) => a.date.localeCompare(b.date))); - setAccounts(acc); - setGroups(cat); - } catch (e) { - toast(e.message, "error"); - } - } - useEffect(() => { laden(); }, []); - - // Der Empfaengertext ist oft genau das, was man nachschlagen will („welches - // Franchise steckt hinter diesem Namen?"). Abtippen faellt damit weg. - async function kopieren(text) { - try { - await navigator.clipboard.writeText(text); - toast(t("open.copied")); - } catch { - toast(t("open.copyFailed"), "error"); - } - } - - async function zuordnen(tx, categoryId) { - if (categoryId == null) return; - try { - await api.updateTransaction(tx.id, { category_id: Number(categoryId) }); - // Aus dem Zweck ein Schlagwort machen, damit die nächste gleiche Buchung - // schon beim Import zugeordnet wird. - const wort = (tx.payee || tx.description || "").trim(); - if (merken && wort) { - try { - await api.addKeyword(Number(categoryId), { pattern: wort, match_field: "any" }); - } catch { /* Schlagwort existiert schon oder kollidiert – nicht schlimm */ } - } - setRows((r) => r.filter((x) => x.id !== tx.id)); - } catch (e) { - toast(e.message, "error"); - } - } - - if (rows === null) return
{t("common.loading")}
; - - return ( -
-
-

{t("open.title")}

-

{t("open.subtitle", { n: rows.length })}

-
- - {rows.length === 0 ? ( -
-
- -

{t("open.allDone")}

-
-
- ) : ( -
- -
- {rows.map((tx) => ( -
- -
-
- {tx.payee || tx.description || t("open.noText")} - {(tx.payee || tx.description) && ( - - )} -
-
- {formatDate(tx.date)} · {accountById[tx.account_id]?.name || "—"} - {tx.description && tx.payee && tx.description !== tx.payee && ` · ${tx.description}`} -
-
- -
- zuordnen(tx, id)} - placeholder={t("open.choose")} - /> -
-
- ))} -
-
- )} -
- ); -} diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index a36e3e4..c7f091c 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -81,6 +81,18 @@ export default function Transactions() { } } + async function kategorieSetzen(tx, categoryId) { + if (categoryId == null) return; + try { + const neu = await api.updateTransaction(tx.id, { category_id: Number(categoryId) }); + // In der Liste ersetzen statt alles neu zu laden: Die Zeile ändert sich + // sofort, und die Bildlaufposition bleibt, wo sie war. + setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x))); + } catch (err) { + toast(err.message, "error"); + } + } + async function loesen(tx) { const link = links[String(tx.id)]; if (!link) return; @@ -106,6 +118,7 @@ export default function Transactions() { // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); + const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const sparenCatId = useMemo(() => { for (const g of expenseGroups) for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id); @@ -242,7 +255,21 @@ export default function Transactions() {
{l.title}
- {l.sub &&
{l.sub}
} + {/* Die Kategorie direkt in der Zeile ändern – dafür musste man + vorher jede Buchung einzeln zum Bearbeiten öffnen. Ein + Übertrag bekommt keine, dort bleibt der Text stehen. */} + {tx.type === "transfer" ? ( + l.sub &&
{l.sub}
+ ) : ( +
+ kategorieSetzen(tx, id)} + placeholder={t("transactions.uncategorized")} + /> +
+ )} {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. Ohne das Gegenkonto sagt „Übertrag" nichts. */} {links[String(tx.id)] && ( @@ -294,7 +321,8 @@ export default function Transactions() { ), }, // eslint-disable-next-line react-hooks/exhaustive-deps - ], [accountById, categoryById, baseCurrency, formatDate, links, t]); + ], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups, + formatDate, links, t]); return (