From b303c8780126e5241ec275eb813a18b7b397c88e Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 10:25:20 +0200 Subject: [PATCH] feat: Eine Arbeitsliste, und Kategorien direkt in der Buchungsliste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Eingang' und 'Offene Buchungen' zeigten fast dasselbe. Seit angebundene Konten selbst buchen, ist eine Bank-Buchung sofort gebucht und ohne Kategorie – sie stand damit in BEIDEN Listen. Die Trennung stammte aus der Zeit, als der Eingang nur Vorschlaege enthielt. Jetzt eine Seite 'Zu erledigen' mit allem, was noch eine Hand braucht: offene Vorschlaege zum Buchen oder Uebertragen und Buchungen ohne Kategorie, egal ob von Bank, Mail, Kurzbefehl oder Datei. Aelteste zuerst, mit der Herkunft in der Zeile. Erledigtes verschwindet – die Eingangs-Eintraege bleiben in der Datenbank, weil der spaetere Import gegen sie abgleicht. /offen leitet um. Das Haekchen 'Schlagwort merken' aus der alten Seite bleibt: Es ist der Grund, warum der Stapel mit der Zeit kleiner wird, und der Eingang hatte es nie. Der Uebertrags-Knopf gilt jetzt auch fuer schon gebuchte Buchungen – sonst haette in einer gemeinsamen Liste die eine Haelfte der Zeilen ihn gehabt und die andere nicht. In der Buchungsliste steht die Kategorie jetzt als Auswahl in der Zeile, dort wo bisher nur ihr Name stand. Vorher musste man jede Buchung einzeln zum Bearbeiten oeffnen. Die Zeile aendert sich an Ort und Stelle, die Bildlaufposition bleibt. Ausserdem tragen API-Antworten jetzt 'Cache-Control: no-store'. Ohne das darf ein Browser selbst entscheiden, und der Zurueck-Knopf zeigt Salden von vorhin – bei Geld ist eine veraltete Anzeige schlimmer als eine langsame. Co-Authored-By: Claude Opus 5 --- backend/app/main.py | 14 + backend/app/routers/transactions.py | 1 + backend/app/schemas.py | 3 + backend/tests/test_inbox.py | 9 + web/src/App.jsx | 8 +- web/src/api.js | 5 + web/src/finance.css | 16 ++ web/src/locales/de.json | 16 +- web/src/locales/en.json | 16 +- web/src/pages/Eingang.jsx | 412 +++++++++++++++++++--------- web/src/pages/Offen.jsx | 135 --------- web/src/pages/Transactions.jsx | 32 ++- 12 files changed, 393 insertions(+), 274 deletions(-) delete mode 100644 web/src/pages/Offen.jsx 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 (