diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index d5b2997..43740e5 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -87,6 +87,9 @@ def list_transactions( user: User = Depends(get_current_user), account_id: int | None = None, category_id: int | None = None, + # Zum Abarbeiten: nur Buchungen, die noch keine Kategorie haben. Überträge + # bleiben aussen vor – sie ordnen kein Geld ein, sie verschieben es nur. + uncategorized: bool = False, type: TxnType | None = None, date_from: date | None = None, date_to: date | None = None, @@ -102,6 +105,8 @@ def list_transactions( ) if category_id is not None: q = q.filter(Transaction.category_id == category_id) + if uncategorized: + q = q.filter(Transaction.category_id.is_(None), Transaction.type != TxnType.transfer) if type is not None: q = q.filter(Transaction.type == type) if date_from is not None: diff --git a/backend/tests/test_transactions_offen.py b/backend/tests/test_transactions_offen.py new file mode 100644 index 0000000..f6affac --- /dev/null +++ b/backend/tests/test_transactions_offen.py @@ -0,0 +1,48 @@ +import io +import json + + +def _konto(admin, **kw): + body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0} + body.update(kw) + return admin.post("/api/accounts", json=body).json() + + +def _kategorie(admin): + for g in admin.get("/api/categories").json(): + for c in g["categories"]: + if c["name"] == "Lebensmittel": + return c["id"] + raise AssertionError("Lebensmittel fehlt") + + +def test_offene_buchungen_listet_nur_unkategorisierte(admin): + """Zum Abarbeiten braucht man die Buchungen ohne Kategorie – und zwar nur die.""" + konto = _konto(admin, name="Abarbeiten") + kat = _kategorie(admin) + admin.post("/api/transactions", json={"type": "expense", "date": "2026-04-01", + "account_id": konto["id"], "amount_cents": 1000, + "payee": "Ohne Kategorie"}) + admin.post("/api/transactions", json={"type": "expense", "date": "2026-04-02", + "account_id": konto["id"], "amount_cents": 2000, + "payee": "Mit Kategorie", "category_id": kat}) + admin.post("/api/transactions", json={"type": "income", "date": "2026-04-03", + "account_id": konto["id"], "amount_cents": 3000, + "payee": "Einnahme ohne Kategorie"}) + + offen = admin.get("/api/transactions", params={"uncategorized": "true", + "account_id": konto["id"]}).json() + namen = sorted(t["payee"] for t in offen) + assert namen == ["Einnahme ohne Kategorie", "Ohne Kategorie"] + + +def test_offene_buchungen_lassen_uebertraege_aus(admin): + """Ein Übertrag ordnet kein Geld ein, er verschiebt es – er gehört nicht auf die Liste.""" + a = _konto(admin, name="Von") + b = _konto(admin, name="Nach") + admin.post("/api/transactions", json={"type": "transfer", "date": "2026-04-04", + "account_id": a["id"], "to_account_id": b["id"], + "amount_cents": 5000}) + offen = admin.get("/api/transactions", params={"uncategorized": "true", + "account_id": a["id"]}).json() + assert offen == [] diff --git a/web/src/App.jsx b/web/src/App.jsx index 846593d..c4743b0 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,6 +12,7 @@ import Categories from "./pages/Categories"; import Transactions from "./pages/Transactions"; import Budget from "./pages/Budget"; import Import from "./pages/Import"; +import Offen from "./pages/Offen"; import Users from "./pages/Users"; import Settings from "./pages/Settings"; import TwoFactor from "./pages/TwoFactor"; @@ -48,6 +49,7 @@ function Sidebar() { +
{t("nav.system")}
@@ -134,6 +136,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index eda014d..0db754b 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -42,6 +42,7 @@ const PATHS = { ), check: , + inbox: <>, close: , edit: ( <> diff --git a/web/src/locales/de.json b/web/src/locales/de.json index fcde65f..7c1d311 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -22,7 +22,8 @@ "import": "Import", "system": "System", "users": "Benutzer", - "settings": "Einstellungen" + "settings": "Einstellungen", + "open": "Offene Buchungen" }, "login": { "subtitle": "Bitte anmelden", @@ -442,5 +443,13 @@ "confirmSystem": "Alle Benutzer und deren Daten werden gelöscht und durch das Backup ersetzt. Anschliessend sind alle Sitzungen beendet.", "done": "Wiederherstellung abgeschlossen.", "doneSystem": "Wiederherstellung abgeschlossen. Bitte neu anmelden." + }, + "open": { + "title": "Buchungen ohne Kategorie", + "subtitle": "{{n}} noch zuzuordnen – älteste zuerst.", + "allDone": "Alles zugeordnet.", + "choose": "Kategorie wählen …", + "noText": "Ohne Text", + "rememberKeyword": "Zweck als Schlagwort merken, damit die nächste gleiche Buchung beim Import gleich zugeordnet wird" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0a373b1..49cabbe 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -22,7 +22,8 @@ "import": "Import", "system": "System", "users": "Users", - "settings": "Settings" + "settings": "Settings", + "open": "Open bookings" }, "login": { "subtitle": "Please sign in", @@ -442,5 +443,13 @@ "confirmSystem": "All users and their data will be deleted and replaced by the backup. Every session ends afterwards.", "done": "Restore finished.", "doneSystem": "Restore finished. Please sign in again." + }, + "open": { + "title": "Bookings without a category", + "subtitle": "{{n}} left to sort – oldest first.", + "allDone": "Everything sorted.", + "choose": "Pick a category …", + "noText": "No text", + "rememberKeyword": "Remember the text as a keyword so the next matching booking is categorised on import" } } diff --git a/web/src/pages/Offen.jsx b/web/src/pages/Offen.jsx new file mode 100644 index 0000000..5d948d4 --- /dev/null +++ b/web/src/pages/Offen.jsx @@ -0,0 +1,115 @@ +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(); }, []); + + 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")}
+
+ {formatDate(tx.date)} · {accountById[tx.account_id]?.name || "—"} +
+
+ +
+ zuordnen(tx, id)} + placeholder={t("open.choose")} + /> +
+
+ ))} +
+
+ )} +
+ ); +}