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:
Guido
2026-08-21 20:40:52 +02:00
co-authored by Claude Opus 5
parent 9642901e4e
commit 6390f7c325
7 changed files with 192 additions and 2 deletions
+115
View File
@@ -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>
);
}