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 ;
+
+ 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")}
+ />
+
+
+ ))}
+
+
+ )}
+
+ );
+}