feat: Seite "Buchungen ohne Kategorie" zum Abarbeiten
Bisher musste man in der Gesamtliste nach Luecken suchen. Der Endpunkt kennt jetzt ?uncategorized=true; Uebertraege bleiben aussen vor, denn sie ordnen kein Geld ein, sie verschieben es nur. Die Seite zeigt die offenen Buchungen aelteste zuerst; nach dem Zuordnen verschwindet die Zeile, sodass der Stapel sichtbar kleiner wird. Auf Wunsch wird der Zweck gleich als Schlagwort gemerkt, damit die naechste gleiche Buchung schon beim Import zugeordnet wird. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,9 @@ def list_transactions(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
account_id: int | None = None,
|
account_id: int | None = None,
|
||||||
category_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,
|
type: TxnType | None = None,
|
||||||
date_from: date | None = None,
|
date_from: date | None = None,
|
||||||
date_to: date | None = None,
|
date_to: date | None = None,
|
||||||
@@ -102,6 +105,8 @@ def list_transactions(
|
|||||||
)
|
)
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
q = q.filter(Transaction.category_id == category_id)
|
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:
|
if type is not None:
|
||||||
q = q.filter(Transaction.type == type)
|
q = q.filter(Transaction.type == type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
|
|||||||
@@ -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 == []
|
||||||
@@ -12,6 +12,7 @@ import Categories from "./pages/Categories";
|
|||||||
import Transactions from "./pages/Transactions";
|
import Transactions from "./pages/Transactions";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
import Import from "./pages/Import";
|
import Import from "./pages/Import";
|
||||||
|
import Offen from "./pages/Offen";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import TwoFactor from "./pages/TwoFactor";
|
import TwoFactor from "./pages/TwoFactor";
|
||||||
@@ -48,6 +49,7 @@ function Sidebar() {
|
|||||||
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||||||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||||
|
<NavItem to="/offen" icon="inbox" label={t("nav.open")} />
|
||||||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||||
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
||||||
<div className="nav-section">{t("nav.system")}</div>
|
<div className="nav-section">{t("nav.system")}</div>
|
||||||
@@ -134,6 +136,7 @@ export default function App() {
|
|||||||
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
||||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||||
|
<Route path="/offen" element={<Protected><Offen /></Protected>} />
|
||||||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||||
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
||||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const PATHS = {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
check: <path d="M20 6 9 17l-5-5" />,
|
check: <path d="M20 6 9 17l-5-5" />,
|
||||||
|
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>,
|
||||||
close: <path d="M18 6 6 18M6 6l12 12" />,
|
close: <path d="M18 6 6 18M6 6l12 12" />,
|
||||||
edit: (
|
edit: (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+10
-1
@@ -22,7 +22,8 @@
|
|||||||
"import": "Import",
|
"import": "Import",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"open": "Offene Buchungen"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"subtitle": "Bitte anmelden",
|
"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.",
|
"confirmSystem": "Alle Benutzer und deren Daten werden gelöscht und durch das Backup ersetzt. Anschliessend sind alle Sitzungen beendet.",
|
||||||
"done": "Wiederherstellung abgeschlossen.",
|
"done": "Wiederherstellung abgeschlossen.",
|
||||||
"doneSystem": "Wiederherstellung abgeschlossen. Bitte neu anmelden."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -22,7 +22,8 @@
|
|||||||
"import": "Import",
|
"import": "Import",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"open": "Open bookings"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"subtitle": "Please sign in",
|
"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.",
|
"confirmSystem": "All users and their data will be deleted and replaced by the backup. Every session ends afterwards.",
|
||||||
"done": "Restore finished.",
|
"done": "Restore finished.",
|
||||||
"doneSystem": "Restore finished. Please sign in again."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <div className="page"><div className="card">{t("common.loading")}</div></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{t("open.title")}</h1>
|
||||||
|
<p className="muted">{t("open.subtitle", { n: rows.length })}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="empty-state">
|
||||||
|
<Icon name="check" size={28} />
|
||||||
|
<p>{t("open.allDone")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<label className="check-inline" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
|
<input type="checkbox" checked={merken} onChange={(e) => setMerken(e.target.checked)} />
|
||||||
|
<span>{t("open.rememberKeyword")}</span>
|
||||||
|
</label>
|
||||||
|
<div className="account-list">
|
||||||
|
{rows.map((tx) => (
|
||||||
|
<div className="account-row" key={tx.id}>
|
||||||
|
<IconChip name={accountById[tx.account_id]?.icon || "wallet"}
|
||||||
|
color={accountById[tx.account_id]?.color} />
|
||||||
|
<div className="named-text grow">
|
||||||
|
<div><strong>{tx.payee || tx.description || t("open.noText")}</strong></div>
|
||||||
|
<div className="named-sub">
|
||||||
|
{formatDate(tx.date)} · {accountById[tx.account_id]?.name || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Money cents={tx.amount_cents} currency={accountById[tx.account_id]?.currency}
|
||||||
|
baseCurrency={baseCurrency} sign={tx.type === "income" ? "pos" : "neg"} />
|
||||||
|
<div style={{ minWidth: 220 }}>
|
||||||
|
<CategorySelect
|
||||||
|
groups={tx.type === "income" ? incomeGroups : expenseGroups}
|
||||||
|
value=""
|
||||||
|
onChange={(id) => zuordnen(tx, id)}
|
||||||
|
placeholder={t("open.choose")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user