Files
certain/web/src/pages/Eingang.jsx
T
GuidoandClaude Opus 5 335a7d55c8 feat: Gleich aussehende Buchungen stehen in der Arbeitsliste
Bisher fand man Dubletten nur zufaellig beim Durchscrollen der Buchungsliste –
oder ich habe ein Skript dafuer geschrieben, und der Nutzer musste sich auf
einen Server einloggen, um seine eigenen Zahlen zu reparieren. Beides ist
falsch.

Die Arbeitsliste zeigt sie jetzt: gleiches Konto, gleicher Tag, gleicher
Betrag, gleicher Text. Je Zeile steht die Kennung der Bank dabei, und die
entscheidet die Frage – dieselbe Kennung heisst sicher dieselbe Zahlung,
verschiedene heissen, die Bank hat sie als verschieden gemeldet.

Entschieden wird bewusst NICHTS von selbst. Drei Aufrundungen ueber 1.00 am
selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein; das weiss
nur der Nutzer. Certain meldet und laesst loeschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:32:40 +02:00

353 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Dubletten from "../components/Dubletten";
import UebertragDialog from "../components/UebertragDialog";
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
import { useToast } from "../toast";
import { useConfirm } from "../confirm";
import { useSettings } from "../settings";
import { gruppenFuer } from "../kategorien";
import { formatDate } from "../format";
// Die Arbeitsliste: alles, was noch eine Hand braucht – aus welcher Quelle auch
// immer. Zwei Arten von Zeilen:
//
// 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() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const { baseCurrency } = useSettings();
const [inbox, setInbox] = useState(null);
const [buchungen, setBuchungen] = useState([]);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [merken, setMerken] = useState(true);
const [uebertrag, setUebertrag] = useState(null);
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 [items, tx, acc, cat] = await Promise.all([
api.listInbox(),
api.listTransactions({ uncategorized: true }),
api.listAccounts(),
api.listCategories(),
]);
setInbox(items);
setBuchungen(tx);
setAccounts(acc);
setGroups(cat);
} catch (e) {
toast(e.message, "error");
}
}
useEffect(() => { laden(); }, []);
// 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;
try {
const neu = await api.setInboxCategory(e.id, categoryId);
setInbox((r) => r.map((x) => (x.id === e.id ? neu : x)));
} catch (err) {
toast(err.message, "error");
}
}
async function zuordnenBuchung(tx, categoryId) {
if (categoryId == null) return;
try {
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();
} catch (err) {
toast(err.message, "error");
}
}
async function alleBuchen() {
try {
const r = await api.bookAllInbox();
toast(t("inbox.bookedN", { n: r.gebucht }));
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function verwerfen(e) {
if (!(await confirm({
title: t("inbox.discard"),
message: t("inbox.discardConfirm"),
danger: true,
confirmLabel: t("inbox.discard"),
}))) return;
try {
await api.discardInboxItem(e.id);
laden();
} catch (err) {
toast(err.message, "error");
}
}
// 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);
}
if (inbox === null) {
return <div className="card"><div className="empty-state">…</div></div>;
}
return (
<div className="page">
<div className="page-head">
<h1>{t("todo.title")}</h1>
<p className="muted">{t("todo.subtitle", { n: aufgaben.length })}</p>
{offeneVorschlaege.length > 0 && (
<button className="btn primary" onClick={alleBuchen}>
<Icon name="check" size={15} /> {t("inbox.bookAll")}
</button>
)}
</div>
{/* Nach dem Buchen entsteht die Gegenseite eines Übertrags erst –
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
<Uebertragsvorschlaege baseCurrency={baseCurrency} onChange={laden} />
{/* Gleich aussehende Buchungen gehören hierher: Sie sind Arbeit, die
liegen bleibt, und man findet sie in der Buchungsliste nur zufällig. */}
<Dubletten accountById={accountById} 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 && (
<UebertragDialog
vorgang={uebertrag}
accounts={accounts}
accountById={accountById}
baseCurrency={baseCurrency}
onClose={() => setUebertrag(null)}
onFertig={laden}
/>
)}
</div>
);
}
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 (
<div className="account-row">
<IconChip name={konto?.icon || "wallet"} color={konto?.color || "#6b7480"} size={26} />
<div className="named-text grow">
<div className="row" style={{ gap: "var(--sp-2)", alignItems: "center" }}>
<strong>{text || t("inbox.noText")}</strong>
{text && (
<button type="button" className="btn sm ghost" title={t("open.copy")}
onClick={() => onKopieren(text)}>
<Icon name="copy" size={14} />
</button>
)}
</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>
);
}