-
{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 && (
{t("inbox.bookAll")}
@@ -138,6 +238,44 @@ export default function Eingang() {
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
+ {aufgaben.length === 0 ? (
+
+
+
+
{t("todo.allDone")}
+
+
+ ) : (
+
+
+ setMerken(e.target.checked)} />
+ {t("open.rememberKeyword")}
+
+
+
+ {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 || "—"}
-
{/* Die Frage hängt an der Richtung – „wohin" bei einem Abgang,
„woher" bei einem Zugang. */}
- {uebertrag.item.amount_cents < 0
+ {uebertrag.betrag < 0
? t("inbox.transferTargetOut")
: t("inbox.transferTargetIn")}
a.id !== uebertrag.item.account_id)}
+ accounts={accounts.filter((a) => a.id !== uebertrag.kontoId)}
value={uebertrag.ziel}
onChange={(id) => setUebertrag((u) => ({ ...u, ziel: id ?? "" }))}
/>
)}
+
-
- {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 && (
- <>
- buchen(item)}>
- {t("inbox.book")}
-
- {/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg
- bliebe nur, sie als solche zu kategorisieren. */}
- setUebertrag({ item, ziel: "" })}>
-
-
- >
- )}
- verwerfen(item)}>
-
-
-
- );
- })}
-
+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 && (
+ onKopieren(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 && (
+
+ {t("inbox.book")}
+
+ )}
+ {/* 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
;
-
- return (
-
-
-
{t("open.title")}
-
{t("open.subtitle", { n: rows.length })}
-
-
- {rows.length === 0 ? (
-
-
-
-
{t("open.allDone")}
-
-
- ) : (
-
-
- setMerken(e.target.checked)} />
- {t("open.rememberKeyword")}
-
-
- {rows.map((tx) => (
-
-
-
-
- {tx.payee || tx.description || t("open.noText")}
- {(tx.payee || tx.description) && (
- kopieren(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 (