Files
certain/web/src/pages/Eingang.jsx
T
GuidoandClaude Opus 5 64b32cf8b8 fix: Tabelle passt wieder, und Uebertraege gehen auch aus der Buchungsliste
Die neue Kategoriespalte hatte 230 px Mindestbreite hinzugefuegt. Damit war die
Tabelle in einem schmalen Fenster breiter als ihre Karte und scrollte waagerecht
- von aussen sah das aus, als waere sie abgeschnitten. Alle Spalten sind jetzt
enger bemessen; bei 900 px Fensterbreite passt sie ohne Scrollen.

Ausserdem liess sich eine Buchung nur aus der Arbeitsliste zum Uebertrag
machen, nicht aus der Buchungsliste. Dort steht der Knopf jetzt auch - und zwar
statt des Loesen-Knopfes, denn beides zugleich gibt es nie: Eine verknuepfte
Buchung IST schon ein Paar.

Der Dialog dafuer ist eine eigene Komponente geworden. Die Nachfrage bei einem
geschaetzten Betrag - 409 abfangen, begruenden lassen, mit 'trotzdem' erneut
senden - ist zu leicht falsch nachgebaut, und jetzt gibt es sie nur einmal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:01:47 +02:00

348 lines
12 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 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} />
{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>
);
}