feat: Eine Arbeitsliste, und Kategorien direkt in der Buchungsliste

'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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-27 10:25:20 +02:00
co-authored by Claude Opus 5
parent 8309f58a67
commit b303c87801
12 changed files with 393 additions and 274 deletions
+14
View File
@@ -161,6 +161,20 @@ if settings.cors_origins.strip():
allow_headers=["*"], 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 ------------------------------------------------------------------- # --- API -------------------------------------------------------------------
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt: # Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an # Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
+1
View File
@@ -45,6 +45,7 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
category_id=txn.category_id, category_id=txn.category_id,
budget_treatment=txn.budget_treatment, budget_treatment=txn.budget_treatment,
budget_category_id=txn.budget_category_id, budget_category_id=txn.budget_category_id,
import_batch_id=txn.import_batch_id,
payee=txn.payee, payee=txn.payee,
description=txn.description, description=txn.description,
notes=txn.notes, notes=txn.notes,
+3
View File
@@ -323,6 +323,9 @@ class TransactionOut(BaseModel):
category_id: int | None category_id: int | None
budget_treatment: BudgetTreatment | None budget_treatment: BudgetTreatment | None
budget_category_id: int | 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 payee: str | None
description: str | None description: str | None
notes: str | None notes: str | None
+9
View File
@@ -342,3 +342,12 @@ def test_uebernehmen_ohne_bank_saldo_wird_erklaert(admin):
r = admin.post(f"/api/accounts/{konto['id']}/saldo-uebernehmen") r = admin.post(f"/api/accounts/{konto['id']}/saldo-uebernehmen")
assert r.status_code == 400 assert r.status_code == 400
assert "abrufen" in r.json()["detail"] 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"
+4 -4
View File
@@ -14,7 +14,6 @@ import Budget from "./pages/Budget";
import Import from "./pages/Import"; import Import from "./pages/Import";
import BankRueckkehr from "./pages/BankRueckkehr"; import BankRueckkehr from "./pages/BankRueckkehr";
import Eingang from "./pages/Eingang"; import Eingang from "./pages/Eingang";
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";
@@ -51,8 +50,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="/eingang" icon="inbox" label={t("nav.todo")} />
<NavItem to="/eingang" icon="smartphone" label={t("nav.inbox")} />
<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>
@@ -139,7 +137,9 @@ 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>} /> {/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen,
stand jede Bank-Buchung ohne Kategorie in beiden Listen. */}
<Route path="/offen" element={<Navigate to="/eingang" replace />} />
<Route path="/eingang" element={<Protected><Eingang /></Protected>} /> <Route path="/eingang" element={<Protected><Eingang /></Protected>} />
<Route path="/bank/rueckkehr" element={<Protected><BankRueckkehr /></Protected>} /> <Route path="/bank/rueckkehr" element={<Protected><BankRueckkehr /></Protected>} />
<Route path="/budget" element={<Protected wide><Budget /></Protected>} /> <Route path="/budget" element={<Protected wide><Budget /></Protected>} />
+5
View File
@@ -215,6 +215,11 @@ export const api = {
// --- Überträge zwischen eigenen Konten ----------------------------------- // --- Überträge zwischen eigenen Konten -----------------------------------
// Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird // Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird
// angefasst – deshalb ist auch eine falsche Verknüpfung folgenlos. // 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"), transactionLinks: () => request("/transactions/links"),
searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }), searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }),
connectTransfer: (a_id, b_id) => connectTransfer: (a_id, b_id) =>
+16
View File
@@ -1307,3 +1307,19 @@ tr.row-dup {
scroll-margin-top: var(--sp-3); 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;
}
+14 -2
View File
@@ -23,8 +23,7 @@
"system": "System", "system": "System",
"users": "Benutzer", "users": "Benutzer",
"settings": "Einstellungen", "settings": "Einstellungen",
"open": "Offene Buchungen", "todo": "Zu erledigen"
"inbox": "Eingang"
}, },
"login": { "login": {
"subtitle": "Bitte anmelden", "subtitle": "Bitte anmelden",
@@ -663,5 +662,18 @@
"linked": "Übertrag", "linked": "Übertrag",
"release": "Verknüpfung lösen", "release": "Verknüpfung lösen",
"releaseConfirm": "Die beiden Buchungen bleiben unverändert stehen und zählen danach wieder als Ausgabe und Einnahme." "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"
}
} }
} }
+14 -2
View File
@@ -23,8 +23,7 @@
"system": "System", "system": "System",
"users": "Users", "users": "Users",
"settings": "Settings", "settings": "Settings",
"open": "Open bookings", "todo": "To do"
"inbox": "Captured"
}, },
"login": { "login": {
"subtitle": "Please sign in", "subtitle": "Please sign in",
@@ -663,5 +662,18 @@
"linked": "Transfer", "linked": "Transfer",
"release": "Release link", "release": "Release link",
"releaseConfirm": "Both transactions stay exactly as they are and count as expense and income again afterwards." "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"
}
} }
} }
+283 -129
View File
@@ -2,10 +2,10 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api"; import { api } from "../api";
import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import IconChip from "../components/IconChip"; import IconChip from "../components/IconChip";
import AccountSelect from "../components/AccountSelect";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import Money from "../components/Money"; import Money from "../components/Money";
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege"; import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
@@ -15,19 +15,31 @@ import { useSettings } from "../settings";
import { gruppenFuer } from "../kategorien"; import { gruppenFuer } from "../kategorien";
import { formatDate } from "../format"; import { formatDate } from "../format";
// Was unterwegs erfasst wurde – per Kurzbefehl vom Telefon. Die Einträge bleiben // Die Arbeitsliste: alles, was noch eine Hand braucht – aus welcher Quelle auch
// hier stehen, auch nachdem sie gebucht sind: Der spätere Kontoauszug gleicht // immer. Zwei Arten von Zeilen:
// gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal in den Saldo läuft. //
// 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() { export default function Eingang() {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const { baseCurrency } = useSettings(); const { baseCurrency } = useSettings();
const [rows, setRows] = useState(null); const [inbox, setInbox] = useState(null);
const [buchungen, setBuchungen] = useState([]);
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = 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 [uebertrag, setUebertrag] = useState(null);
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
@@ -37,10 +49,14 @@ export default function Eingang() {
async function laden() { async function laden() {
try { try {
const [items, acc, cat] = await Promise.all([ const [items, tx, acc, cat] = await Promise.all([
api.listInbox(), api.listAccounts(), api.listCategories(), api.listInbox(),
api.listTransactions({ uncategorized: true }),
api.listAccounts(),
api.listCategories(),
]); ]);
setRows(items); setInbox(items);
setBuchungen(tx);
setAccounts(acc); setAccounts(acc);
setGroups(cat); setGroups(cat);
} catch (e) { } catch (e) {
@@ -49,22 +65,69 @@ export default function Eingang() {
} }
useEffect(() => { laden(); }, []); 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; if (categoryId == null) return;
try { try {
await api.setInboxCategory(item.id, categoryId); const neu = await api.setInboxCategory(e.id, categoryId);
setRows((r) => r.map((x) => (x.id === item.id ? { ...x, category_id: categoryId } : x))); setInbox((r) => r.map((x) => (x.id === e.id ? neu : x)));
} catch (e) { } catch (err) {
toast(e.message, "error"); toast(err.message, "error");
} }
} }
async function buchen(item) { async function zuordnenBuchung(tx, categoryId) {
if (categoryId == null) return;
try { 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(); laden();
} catch (e) { } catch (err) {
toast(e.message, "error"); toast(err.message, "error");
} }
} }
@@ -73,39 +136,12 @@ export default function Eingang() {
const r = await api.bookAllInbox(); const r = await api.bookAllInbox();
toast(t("inbox.bookedN", { n: r.gebucht })); toast(t("inbox.bookedN", { n: r.gebucht }));
laden(); laden();
} catch (e) { } catch (err) {
toast(e.message, "error"); toast(err.message, "error");
} }
} }
async function uebertragBuchen() { async function verwerfen(e) {
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) {
if (!(await confirm({ if (!(await confirm({
title: t("inbox.discard"), title: t("inbox.discard"),
message: t("inbox.discardConfirm"), message: t("inbox.discardConfirm"),
@@ -113,21 +149,85 @@ export default function Eingang() {
confirmLabel: t("inbox.discard"), confirmLabel: t("inbox.discard"),
}))) return; }))) return;
try { try {
await api.discardInboxItem(item.id); await api.discardInboxItem(e.id);
laden(); laden();
} catch (e) { } catch (err) {
toast(e.message, "error"); toast(err.message, "error");
} }
} }
if (rows === null) return <div className="card"><div className="empty-state">…</div></div>; // 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 <div className="card"><div className="empty-state">…</div></div>;
}
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
<h1>{t("inbox.title")}</h1> <h1>{t("todo.title")}</h1>
<p className="muted">{t("inbox.intro")}</p> <p className="muted">{t("todo.subtitle", { n: aufgaben.length })}</p>
{rows.some((r) => r.status === "pending" && r.account_id) && ( {offeneVorschlaege.length > 0 && (
<button className="btn primary" onClick={alleBuchen}> <button className="btn primary" onClick={alleBuchen}>
<Icon name="check" size={15} /> {t("inbox.bookAll")} <Icon name="check" size={15} /> {t("inbox.bookAll")}
</button> </button>
@@ -138,6 +238,44 @@ export default function Eingang() {
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */} deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
<Uebertragsvorschlaege baseCurrency={baseCurrency} onChange={laden} /> <Uebertragsvorschlaege baseCurrency={baseCurrency} onChange={laden} />
{aufgaben.length === 0 ? (
<div className="card">
<div className="empty-state">
<Icon name="check" size={28} />
<p>{t("todo.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">
{aufgaben.map((aufgabe) => (
<Zeile
key={aufgabe.schluessel}
aufgabe={aufgabe}
accountById={accountById}
baseCurrency={baseCurrency}
expenseGroups={expenseGroups}
incomeGroups={incomeGroups}
t={t}
onKategorie={(id) => (aufgabe.art === "vorschlag"
? zuordnenVorschlag(aufgabe.e, id)
: zuordnenBuchung(aufgabe.tx, id))}
onBuchen={() => buchen(aufgabe.e)}
onUebertrag={() => oeffneUebertrag(aufgabe)}
onVerwerfen={() => verwerfen(aufgabe.e)}
onKopieren={kopieren}
/>
))}
</div>
</div>
)}
{uebertrag && ( {uebertrag && (
<Modal <Modal
title={t("inbox.transferTitle")} title={t("inbox.transferTitle")}
@@ -157,101 +295,117 @@ export default function Eingang() {
<p className="muted small">{t("inbox.transferIntro")}</p> <p className="muted small">{t("inbox.transferIntro")}</p>
<div className="account-row" style={{ borderBottom: "none" }}> <div className="account-row" style={{ borderBottom: "none" }}>
<div className="named-text grow"> <div className="named-text grow">
<div><strong>{uebertrag.item.payee || t("inbox.noText")}</strong></div> <div><strong>{uebertrag.text || t("inbox.noText")}</strong></div>
<div className="named-sub"> <div className="named-sub">
{formatDate(uebertrag.item.date)} ·{" "} {formatDate(uebertrag.datum)} ·{" "}
{accountById[uebertrag.item.account_id]?.name || "—"} {accountById[uebertrag.kontoId]?.name || "—"}
</div> </div>
</div> </div>
<Money cents={Math.abs(uebertrag.item.amount_cents)} <Money cents={Math.abs(uebertrag.betrag)} currency={uebertrag.waehrung}
currency={uebertrag.item.currency} sign={uebertrag.betrag < 0 ? "neg" : "pos"}
sign={uebertrag.item.amount_cents < 0 ? "neg" : "pos"}
baseCurrency={baseCurrency} /> baseCurrency={baseCurrency} />
</div> </div>
<label> <label>
{/* Die Frage hängt an der Richtung – „wohin" bei einem Abgang, {/* Die Frage hängt an der Richtung – „wohin" bei einem Abgang,
„woher" bei einem Zugang. */} „woher" bei einem Zugang. */}
<span> <span>
{uebertrag.item.amount_cents < 0 {uebertrag.betrag < 0
? t("inbox.transferTargetOut") ? t("inbox.transferTargetOut")
: t("inbox.transferTargetIn")} : t("inbox.transferTargetIn")}
</span> </span>
<AccountSelect <AccountSelect
accounts={accounts.filter((a) => a.id !== uebertrag.item.account_id)} accounts={accounts.filter((a) => a.id !== uebertrag.kontoId)}
value={uebertrag.ziel} value={uebertrag.ziel}
onChange={(id) => setUebertrag((u) => ({ ...u, ziel: id ?? "" }))} onChange={(id) => setUebertrag((u) => ({ ...u, ziel: id ?? "" }))}
/> />
</label> </label>
</Modal> </Modal>
)} )}
</div>
);
}
{rows.length === 0 ? ( function Zeile({
<div className="card"><div className="empty-state">{t("inbox.empty")}</div></div> aufgabe, accountById, baseCurrency, expenseGroups, incomeGroups, t,
) : ( onKategorie, onBuchen, onUebertrag, onVerwerfen, onKopieren,
<div className="card"> }) {
<div className="account-list"> const vorschlag = aufgabe.art === "vorschlag";
{rows.map((item) => { const e = aufgabe.e;
const konto = accountById[item.account_id]; const tx = aufgabe.tx;
const ausgabe = item.amount_cents < 0;
return ( const kontoId = vorschlag ? e.account_id : tx.account_id;
<div className="account-row" key={item.id}> const konto = accountById[kontoId];
<IconChip name={konto?.icon || "wallet"} color={konto?.color || "#6b7480"} size={26} /> const betrag = vorschlag ? e.amount_cents : tx.amount_cents;
<div className="named-text grow"> const ausgabe = vorschlag ? betrag < 0 : tx.type !== "income";
<div><strong>{item.payee || t("inbox.noText")}</strong></div> const text = vorschlag ? e.payee : (tx.payee || tx.description);
<div className="named-sub"> const uebertragsKonto = vorschlag ? e.transfer_account_id : null;
{/* Jede Angabe für sich unzertrennlich: Auf dem Telefon
brach sonst „Revolut CHF" um oder wurde gekürzt – und return (
genau die Währung im Namen unterscheidet die Konten. */} <div className="account-row">
<span className="nowrap">{formatDate(item.date)}</span> <IconChip name={konto?.icon || "wallet"} color={konto?.color || "#6b7480"} size={26} />
{" · "} <div className="named-text grow">
<span className="nowrap">{konto?.name || "—"}</span> <div className="row" style={{ gap: "var(--sp-2)", alignItems: "center" }}>
{item.source === "shortcut" && ` · ${t("inbox.viaShortcut")}`} <strong>{text || t("inbox.noText")}</strong>
{item.source === "mail" && ` · ${t("inbox.viaMail")}`} {text && (
{item.status === "unclear" && ` · ${t("inbox.unclear")}`} <button type="button" className="btn sm ghost" title={t("open.copy")}
{item.status === "booked" && ` · ${t("inbox.booked")}`} onClick={() => onKopieren(text)}>
</div> <Icon name="copy" size={14} />
</div> </button>
<Money cents={Math.abs(item.amount_cents)} currency={item.currency} )}
sign={ausgabe ? "neg" : "pos"} baseCurrency={baseCurrency} />
{/* Ein Übertrag ist keine Ausgabe – er bekommt keine
Kategorie, also auch keine Auswahl dafür. */}
{item.transfer_account_id != null ? (
<div className="uebertrag-marke">
<Icon name="arrow-left-right" size={14} />
<span className="ellipsis">
{accountById[item.transfer_account_id]?.name
|| t("transfers.linked")}
</span>
</div>
) : (
<CategorySelect
groups={ausgabe ? expenseGroups : incomeGroups}
value={item.category_id}
onChange={(id) => zuordnen(item, id)}
/>
)}
{item.status !== "booked" && item.account_id && (
<>
<button className="btn sm primary" onClick={() => buchen(item)}>
<Icon name="check" size={14} /> {t("inbox.book")}
</button>
{/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg
bliebe nur, sie als solche zu kategorisieren. */}
<button className="btn sm" title={t("inbox.asTransfer")}
onClick={() => setUebertrag({ item, ziel: "" })}>
<Icon name="arrow-left-right" size={14} />
</button>
</>
)}
<button className="btn sm ghost" title={t("inbox.discard")}
onClick={() => verwerfen(item)}>
<Icon name="trash" size={15} />
</button>
</div>
);
})}
</div>
</div> </div>
<div className="named-sub">
{/* Jede Angabe für sich unzertrennlich: Auf dem Telefon brach sonst
„Revolut CHF" um – und genau die Währung im Namen unterscheidet
die Konten. */}
<span className="nowrap">{formatDate(aufgabe.datum)}</span>
{" · "}
<span className="nowrap">{konto?.name || "—"}</span>
{" · "}
<span className="nowrap">
{t(`todo.from.${vorschlag ? e.source : aufgabe.quelle}`, {
defaultValue: vorschlag ? e.source : aufgabe.quelle,
})}
</span>
{vorschlag && e.status === "unclear" && ` · ${t("inbox.unclear")}`}
</div>
</div>
<Money cents={Math.abs(betrag)} currency={vorschlag ? e.currency : konto?.currency}
sign={ausgabe ? "neg" : "pos"} baseCurrency={baseCurrency} />
{/* Ein Übertrag ist keine Ausgabe – er bekommt keine Kategorie. */}
{uebertragsKonto != null ? (
<div className="uebertrag-marke">
<Icon name="arrow-left-right" size={14} />
<span className="ellipsis">
{accountById[uebertragsKonto]?.name || t("transfers.linked")}
</span>
</div>
) : (
<CategorySelect
groups={ausgabe ? expenseGroups : incomeGroups}
value={vorschlag ? e.category_id : ""}
onChange={onKategorie}
placeholder={t("open.choose")}
/>
)}
{vorschlag && e.account_id && (
<button className="btn sm primary" onClick={onBuchen}>
<Icon name="check" size={14} /> {t("inbox.book")}
</button>
)}
{/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg bliebe nur, sie als
solche zu kategorisieren. */}
{uebertragsKonto == null && kontoId && (
<button className="btn sm" title={t("inbox.asTransfer")} onClick={onUebertrag}>
<Icon name="arrow-left-right" size={14} />
</button>
)}
{vorschlag && (
<button className="btn sm ghost" title={t("inbox.discard")} onClick={onVerwerfen}>
<Icon name="trash" size={15} />
</button>
)} )}
</div> </div>
); );
-135
View File
@@ -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 <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 className="row" style={{ gap: "var(--sp-2)", alignItems: "center" }}>
<strong>{tx.payee || tx.description || t("open.noText")}</strong>
{(tx.payee || tx.description) && (
<button type="button" className="btn sm ghost" title={t("open.copy")}
onClick={() => kopieren(tx.payee || tx.description)}>
<Icon name="copy" size={14} />
</button>
)}
</div>
<div className="named-sub">
{formatDate(tx.date)} · {accountById[tx.account_id]?.name || "—"}
{tx.description && tx.payee && tx.description !== tx.payee && ` · ${tx.description}`}
</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>
);
}
+30 -2
View File
@@ -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) { async function loesen(tx) {
const link = links[String(tx.id)]; const link = links[String(tx.id)];
if (!link) return; if (!link) return;
@@ -106,6 +118,7 @@ export default function Transactions() {
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
const sparenCatId = useMemo(() => { const sparenCatId = useMemo(() => {
for (const g of expenseGroups) for (const g of expenseGroups)
for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id); for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id);
@@ -242,7 +255,21 @@ export default function Transactions() {
<IconChip name={l.icon} color={l.color} size={26} /> <IconChip name={l.icon} color={l.color} size={26} />
<div className="named-text"> <div className="named-text">
<div className="ellipsis">{l.title}</div> <div className="ellipsis">{l.title}</div>
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>} {/* 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 && <div className="named-sub ellipsis">{l.sub}</div>
) : (
<div className="zeilen-kategorie">
<CategorySelect
groups={tx.type === "income" ? incomeGroups : expenseGroups}
value={tx.category_id}
onChange={(id) => kategorieSetzen(tx, id)}
placeholder={t("transactions.uncategorized")}
/>
</div>
)}
{/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme.
Ohne das Gegenkonto sagt „Übertrag" nichts. */} Ohne das Gegenkonto sagt „Übertrag" nichts. */}
{links[String(tx.id)] && ( {links[String(tx.id)] && (
@@ -294,7 +321,8 @@ export default function Transactions() {
), ),
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, formatDate, links, t]); ], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
formatDate, links, t]);
return ( return (
<div> <div>