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
…
; } return (

{t("todo.title")}

{t("todo.subtitle", { n: aufgaben.length })}

{offeneVorschlaege.length > 0 && ( )}
{/* Nach dem Buchen entsteht die Gegenseite eines Übertrags erst – deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */} {aufgaben.length === 0 ? (

{t("todo.allDone")}

) : (
{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 && ( setUebertrag(null)} onFertig={laden} /> )}
); } 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 && ( )}
{/* 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 && ( )} {/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg bliebe nur, sie als solche zu kategorisieren. */} {uebertragsKonto == null && kontoId && ( )} {vorschlag && ( )}
); }