import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useSettings } from "../settings"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; import EditableText from "../components/EditableText"; import Money from "../components/Money"; import TagSelect from "../components/TagSelect"; import Modal from "../components/Modal"; import BudgetZeitraum from "../components/BudgetZeitraum"; import AccountSelect from "../components/AccountSelect"; import CategorySelect from "../components/CategorySelect"; import AboMarke from "../components/AboMarke"; import UebertragDialog from "../components/UebertragDialog"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; import { centsToInput, formatMonthLabel, formatMonthShort, formatMoney, parseAmountToCents, todayIso } from "../format"; const TYPES = ["expense", "income", "transfer"]; const SAVINGS_TYPES = ["sparkonto", "depot"]; function emptyForm() { return { type: "expense", date: todayIso(), account_id: "", to_account_id: "", category_id: "", amount: "", to_amount: "", payee: "", description: "", notes: "", tag_ids: [], budget_as_expense: true, budget_category_id: "", // Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es. budget_period: null, }; } export default function Transactions() { const { t, i18n } = useTranslation(); const { baseCurrency, formatDate, budget_start_day: startTag } = useSettings(); const confirm = useConfirm(); const toast = useToast(); const [txns, setTxns] = useState([]); // Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist. const [links, setLinks] = useState({}); // Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt. const [vorschlag, setVorschlag] = useState({}); const [abos, setAbos] = useState([]); // Buchung, die gerade zum Übertrag gemacht wird. const [uebertrag, setUebertrag] = useState(null); const [accounts, setAccounts] = useState([]); const [groups, setGroups] = useState([]); const [tags, setTags] = useState([]); // Drei Haken statt eines Typs: Man will oft zwei Arten gleichzeitig sehen // und eine dritte weglassen, und genau das ging mit einer Einfachauswahl nicht. const [filter, setFilter] = useState({ account_id: "", arten: { expense: true, income: true, uebertrag: true }, }); const [form, setForm] = useState(null); const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(false); const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]); const tagById = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]); // Die Tags einer Zeile als Datensaetze – fuer die Anzeige und fuer den // Spaltenfilter, der mit Namen arbeitet und nicht mit IDs. const tagsVon = (tx) => (tx.tag_ids || []).map((id) => tagById[id]).filter(Boolean); const categoryById = useMemo(() => { const m = {}; for (const g of groups) for (const c of g.categories) m[c.id] = c; return m; }, [groups]); async function ladenStamm() { try { const [accs, cats, tagListe] = await Promise.all([ api.listAccounts(), api.listCategories(), api.listTags(), ]); setAccounts(accs); setGroups(cats); setTags(tagListe); } catch (err) { toast(err.message, "error"); } } async function ladenBuchungen() { try { // Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen, // kein Feld an ihnen – deshalb auch ein eigener Abruf. const [liste, verkn, vorschlaege, aboListe] = await Promise.all([ api.listTransactions({ account_id: filter.account_id }), api.transactionLinks().catch(() => ({})), // Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts // verknüpft werden. api.transferSuggestions().catch((e) => { // Stillschweigen waere hier das Schlimmste: Die Liste saehe aus, als // gaebe es nichts zu verbinden. toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error"); return { vorschlaege: [] }; }), api.listSubscriptions().catch(() => ({ subscriptions: [] })), ]); setTxns(liste); setLinks(verkn); setAbos(aboListe.subscriptions || []); const paare = {}; for (const v of vorschlaege.vorschlaege || []) { paare[v.a.id] = v.b; paare[v.b.id] = v.a; } setVorschlag(paare); } catch (err) { toast(err.message, "error"); } } 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 notizSetzen(tx, text) { try { const neu = await api.updateTransaction(tx.id, { notes: text || null }); setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x))); } catch (err) { toast(err.message, "error"); } } // Je Zeile eine Warteschlange. Die Auswahl bleibt nach einem Klick offen – // man vergibt selten genau einen Tag –, also kommen zwei Klicks schnell // hintereinander. Ohne die Reihung ueberholten sich die beiden Anfragen, und // welche zuletzt ankam, entschied: Der erste Tag fiel wieder ab. const tagLauf = useRef({}); function tagsSetzen(tx, ids) { const vorher = tx.tag_ids || []; // Sofort anzeigen, nicht erst nach der Antwort: Sonst rechnet der zweite // Klick noch mit dem Stand VOR dem ersten und nimmt ihn zurueck. setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: ids } : x))); const lauf = (tagLauf.current[tx.id] || Promise.resolve()) .catch(() => {}) .then(() => api.updateTransaction(tx.id, { tag_ids: ids.map(Number) })) // Die Zeile ersetzen statt alles neu zu laden, sonst springt die // Bildlaufposition bei jedem Klick nach oben. .then((neu) => setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x)))) .catch((err) => { setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: vorher } : x))); toast(err.message, "error"); }); tagLauf.current[tx.id] = lauf; return lauf; } async function verbinden(tx) { const gegen = vorschlag[tx.id]; if (!gegen) return; try { await api.connectTransfer(tx.id, gegen.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } } async function nichtVerbinden(tx) { const gegen = vorschlag[tx.id]; if (!gegen) return; try { // Ablehnen wird gemerkt, sonst fragt Certain bei jedem Laden erneut. await api.ignoreTransfer(tx.id, gegen.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } } // Zuordnen heisst zugleich: lernen. Musste man von Hand ran, hat der // Erkennungstext des Vertrags nicht gepasst; der Server uebernimmt den // Empfaenger dieser Buchung, damit es beim naechsten Mal von selbst faellt. async function aboSetzen(tx, subId) { try { if (subId) await api.assignSubscription(subId, tx.id); else await api.unassignSubscription(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } } async function loesen(tx) { const link = links[String(tx.id)]; if (!link) return; if (!(await confirm({ title: t("transfers.release"), message: t("transfers.releaseConfirm"), confirmLabel: t("transfers.release"), }))) return; try { await api.releaseTransfer(link.link_id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } } useEffect(() => { ladenStamm(); }, []); // Nur das Konto geht an den Server; nach Art wird im Browser gefiltert. useEffect(() => { ladenBuchungen(); }, [filter.account_id]); const fromAcc = form && accountById[Number(form.account_id)]; const toAcc = form && accountById[Number(form.to_account_id)]; const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency; // Sparziel ist das Konto, wenn es eine Spar-Kategorie traegt – oder, als // Vorgabe, wenn sein Typ dafuer steht. Am Typ allein laesst es sich nicht // ablesen: Ein Revolut-Pocket kommt als ganz normales Girokonto herein. const savingsDest = form?.type === "transfer" && toAcc && (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type)); // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). // Eine namenlose Gruppe: CategorySelect zeigt dann keine Kopfzeile. // Alphabetisch, nicht in Anlagereihenfolge – bei zwanzig Vertraegen sucht // man einen bestimmten, und die Reihenfolge der Eingabe kennt niemand. const aboGruppe = useMemo(() => [{ id: 0, name: null, categories: [...abos].sort((a, b) => a.name.localeCompare(b.name, i18n.language)), }], [abos, i18n.language]); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const sparenCatId = useMemo(() => { // Was am Zielkonto steht, schlaegt jede Vermutung: Dort hat es jemand // ausdruecklich gesagt. if (toAcc?.savings_category_id != null) return String(toAcc.savings_category_id); for (const g of expenseGroups) for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id); return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : ""; }, [expenseGroups, toAcc]); // Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen. useEffect(() => { if (savingsDest && form && !form.budget_category_id) { setForm((f) => (f && !f.budget_category_id ? { ...f, budget_category_id: sparenCatId } : f)); } }, [savingsDest, sparenCatId]); // Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen. useEffect(() => { if (!crossCurrency) return; const cents = parseAmountToCents(form.amount); if (!cents || form.to_amount) return; let abbruch = false; api.convertRate(cents, fromAcc.currency, toAcc.currency, form.date) .then((r) => { if (!abbruch && r.converted_cents != null) { setForm((f) => (f && !f.to_amount ? { ...f, to_amount: centsToInput(r.converted_cents) } : f)); } }) .catch(() => {}); return () => { abbruch = true; }; }, [crossCurrency, form?.amount, form?.account_id, form?.to_account_id, form?.date]); function openNew() { setEditing(null); setForm({ ...emptyForm(), account_id: accounts[0]?.id ? String(accounts[0].id) : "" }); } function openEdit(tx) { setEditing(tx); setForm({ type: tx.type, date: tx.date, account_id: String(tx.account_id), to_account_id: tx.to_account_id ? String(tx.to_account_id) : "", category_id: tx.category_id ? String(tx.category_id) : "", amount: centsToInput(tx.amount_cents), to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "", payee: tx.payee || "", description: tx.description || "", notes: tx.notes || "", tag_ids: tx.tag_ids || [], budget_as_expense: tx.budget_treatment !== "neutral", budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "", // Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst: // aus dem Datum – und wandert mit, wenn man das Datum noch aendert. budget_period: tx.budget_period_manuell ? tx.budget_period : null, }); } const catOptions = useMemo(() => { if (!form) return []; const kind = form.type === "income" ? "income" : "expense"; return gruppenFuer(groups, kind); }, [form, groups]); async function speichern(e) { e.preventDefault(); const cents = parseAmountToCents(form.amount); if (!cents || cents <= 0) { toast(t("transactions.amountRequired"), "error"); return; } if (!form.account_id) { toast(t("transactions.accountRequired"), "error"); return; } setBusy(true); const body = { type: form.type, date: form.date, account_id: Number(form.account_id), amount_cents: cents, payee: form.payee.trim() || null, description: form.description.trim() || null, notes: form.notes.trim() || null, tag_ids: form.tag_ids || [], budget_period: form.budget_period || null, }; if (form.type === "transfer") { body.to_account_id = Number(form.to_account_id); const toCents = parseAmountToCents(form.to_amount); if (toCents) body.to_amount_cents = toCents; if (savingsDest) { body.budget_treatment = form.budget_as_expense ? "budgeted" : "neutral"; body.budget_category_id = form.budget_as_expense && form.budget_category_id ? Number(form.budget_category_id) : null; } } else { body.category_id = form.category_id ? Number(form.category_id) : null; } try { if (editing) await api.updateTransaction(editing.id, body); else await api.createTransaction(body); setForm(null); toast(t("settings.saved")); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } finally { setBusy(false); } } async function loeschen(tx) { if (!(await confirm({ title: t("common.delete"), message: t("transactions.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; try { await api.deleteTransaction(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); } } // Ein verknüpftes Paar ist EINE Bewegung, keine zwei. Liegen beide Seiten in // der Liste, wird die Zugangs-Seite ausgeblendet und die Abgangs-Seite zeigt // „von → nach" – so wie ein echter Übertrag. Ist nur eine Seite geladen // (etwa weil nach einem Konto gefiltert wird), bleibt sie einzeln stehen; // dann trägt sie weiter die Marke mit dem Gegenkonto. const { zeilen, paarZu } = useMemo(() => { const vorhanden = new Set(txns.map((t) => t.id)); const paare = {}; const sichtbar = []; for (const tx of txns) { const link = links[String(tx.id)]; const gegenseiteDa = link && vorhanden.has(link.other_id); if (gegenseiteDa && link.richtung === "rein") continue; // von der anderen gezeigt if (gegenseiteDa) paare[tx.id] = txns.find((x) => x.id === link.other_id); sichtbar.push(tx); } // Eine verknuepfte Buchung ist weder Ausgabe noch Einnahme – sie verschiebt // nur Geld (siehe transfers.linkedHint). Nach ihrem rohen `type` zu filtern // zeigte sie unter „Einnahmen", wo sie niemand sucht. const art = (t) => (t.type === "transfer" || links[String(t.id)] ? "uebertrag" : t.type); return { zeilen: sichtbar.filter((t) => filter.arten[art(t)]), paarZu: paare }; }, [txns, links, filter.arten]); function rowLabel(tx) { const gegen = paarZu[tx.id]; if (gegen) { const von = accountById[tx.account_id]?.name || "?"; const nach = accountById[gegen.account_id]?.name || "?"; return { icon: "arrow-left-right", color: "#6b7480", uebertrag: true, title: `${von} → ${nach}`, sub: tx.payee || tx.description || t("transactions.transfer"), }; } // Verknuepft, aber die Gegenseite ist nicht in der Liste (gefiltert oder // auf einer anderen Seite). Dieselbe Darstellung wie beim Paar: Oben steht, // zwischen WELCHEN Konten das Geld lief, und nicht ein Empfaengertext, den // die Bank vergeben hat. // // Vorher stand hier der Rohtext als Titel und die Verknuepfung nur klein // darunter – „To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c" sah aus wie // eine Zahlung an jemanden. const link = links[String(tx.id)]; if (link) { const eigen = accountById[tx.account_id]?.name || "?"; const anderes = accountById[link.other_account_id]?.name || t("transfers.linked"); return { icon: "arrow-left-right", color: "#6b7480", uebertrag: true, title: link.richtung === "raus" ? `${eigen} → ${anderes}` : `${anderes} → ${eigen}`, sub: tx.payee || tx.description || "", }; } if (tx.type === "transfer") { const from = accountById[tx.account_id]?.name || "?"; const to = accountById[tx.to_account_id]?.name || "?"; return { icon: "arrow-left-right", color: "#6b7480", title: `${from} → ${to}`, sub: tx.description || t("transactions.transfer") }; } // Oben steht, WOFÜR die Zahlung war – danach sucht man in der Liste. // Die Kategorie ist die Einordnung und steht klein darunter; das Symbol // trägt sie ohnehin schon. const cat = tx.category_id ? categoryById[tx.category_id] : null; const zweck = tx.payee || tx.description; return { icon: cat?.icon || "more-horizontal", color: cat?.color || "#6b7480", title: zweck || cat?.name || t("transactions.uncategorized"), // Der Verwendungszweck, falls er neben dem Empfänger noch etwas sagt. // Die Kategorie steht in ihrer eigenen Spalte. sub: tx.payee && tx.description && tx.description !== tx.payee ? tx.description : "", }; } const txnColumns = useMemo(() => [ { key: "date", header: t("transactions.date"), width: 100, sortValue: (tx) => tx.date, render: (tx) => {formatDate(tx.date)}, }, { // Wofür die Zahlung war – danach sucht man in der Liste. key: "text", header: t("transactions.subject"), grow: true, min: 190, // Auch die Notiz durchsuchbar: Wer sie schreibt, will spaeter danach // suchen koennen („Geschenk", „beruflich"). filterText: (tx) => [rowLabel(tx).title, tx.notes].filter(Boolean).join(" "), sortValue: (tx) => rowLabel(tx).title, render: (tx) => { const l = rowLabel(tx); return (
{/* Herkunft NEBEN dem Titel, nicht darunter: Die zweite Zeile gehört dem Verwendungszweck, und alles Neue dorthin zu stapeln macht die Zeilen hoch und den Blick eng. */}
{l.title} {/* Ein Uebertrag ist keine Ausgabe und keine Einnahme. Das gehoert in die erste Zeile, nicht in eine Fussnote. */} {l.uebertrag && ( {t("transactions.transfer")} )} {t(`todo.from.${tx.herkunft}`, { defaultValue: tx.herkunft, })} {/* Nur der Ausnahmefall wird gezeigt: Steht hier nichts, gilt das Datum – und das steht ohnehin in seiner eigenen Spalte. */} {tx.budget_period_manuell && ( {formatMonthShort(tx.budget_period, i18n.language)} )}
{l.sub &&
{l.sub}
} {/* Die fruehere Marke mit dem Gegenkonto ist entfallen: Das Gegenkonto steht jetzt im Titel, wo man es sucht. */} {/* Nachträglich erkannt: Auf einem anderen Konto steht der gleiche Betrag am selben Tag in die andere Richtung. Fragen statt tun – und die Antwort merken. */} {!links[String(tx.id)] && vorschlag[tx.id] && (
{t("transfers.question", { konto: accountById[vorschlag[tx.id].account_id]?.name || "?", })}
)}
); }, }, { // Die Notiz stand bisher nur im Bearbeiten-Dialog. Wer sie schreibt, // will sie aber spaeter sehen, ohne jede Zeile aufzuklappen. key: "notes", header: t("transactions.notes"), width: 180, filterText: (tx) => tx.notes || "", render: (tx) => ( notizSetzen(tx, text)} /> ), }, { // Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem // Text – gestapelt wurden die Zeilen hoch und die Spalte ausgefranst. key: "category", header: t("transactions.category"), width: 170, filterText: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "", sortValue: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "", render: (tx) => ( // Ein Übertrag bekommt keine Kategorie – er ordnet kein Geld ein. Eine // VERKNÜPFTE Buchung ebenso wenig: Sie gehört zu einem Übertrag und // zählt in keiner Auswertung, eine Kategorie bewirkt dort nichts. tx.type === "transfer" || links[String(tx.id)] ? ( — ) : ( kategorieSetzen(tx, id)} placeholder={t("transactions.uncategorized")} /> ) ), }, { // Welcher Vertrag diese Zahlung ist. Dieselbe Form wie die Kategorie // daneben: Die Auswahl zeigt den Stand und aendert ihn, leer loest. key: "abo", header: t("abos.column"), width: 160, filterText: (tx) => abos.find((a) => a.id === tx.subscription_id)?.name || "", render: (tx) => ( // Ein Uebertrag ist keine Abo-Zahlung – er verschiebt nur Geld. tx.type === "transfer" || links[String(tx.id)] ? ( — ) : ( // Dieselbe Auswahl wie bei der Kategorie – nur flach (die Vertraege // haben keine Gruppen) und mit dem Markenlogo statt eines Symbols. aboSetzen(tx, id)} renderMarke={(a, size) => } searchPlaceholder={t("abos.searchPlaceholder")} allowNone noneLabel={t("abos.noneAssigned")} placeholder={t("abos.noneAssigned")} /> ) ), }, { // filterValues statt filterText: Eine Zeile hat MEHRERE Tags, und die // DataTable kann das – sie vergleicht dann gegen jeden einzelnen Wert. key: "tags", header: t("tags.column"), width: 170, filterValues: (tx) => tagsVon(tx).map((x) => x.name), render: (tx) => ( tagsSetzen(tx, ids)} placeholder={t("tags.none")} /> ), }, { key: "account", header: t("transactions.account"), width: 130, filterText: (tx) => accountById[tx.account_id]?.name || "", render: (tx) => accountById[tx.account_id]?.name || "", }, { key: "amount", header: t("transactions.amount"), align: "num", width: 140, sortValue: (tx) => tx.amount_base_cents, render: (tx) => { const acc = accountById[tx.account_id]; const gegen = paarZu[tx.id]; if (gegen) { // Ein Paar hat kein Vorzeichen – es verschiebt. Bei verschiedenen // Währungen stehen beide Beträge, sonst verschwände einer. const zielKonto = accountById[gegen.account_id]; const andereWaehrung = zielKonto?.currency !== acc?.currency; if (!andereWaehrung) { return ( ); } // Über zwei Währungen gehören beide Beträge dazu – nebeneinander // passen sie nicht in die Spalte, untereinander schon. Dieselbe // Anordnung nutzt der umgerechnete Wert ohnehin. return ( {formatMoney(tx.amount_cents, acc?.currency || "EUR")} → {formatMoney(gegen.amount_cents, zielKonto?.currency || "EUR")} ); } const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null; return ( ); }, }, { // Angeheftet: Seit Notiz und Tags dazugekommen sind, ist die Tabelle // breiter als der Bildschirm – ohne das scrollten Bearbeiten und // Loeschen einfach aus dem Bild. key: "actions", header: "", width: 118, fixed: true, pin: "right", render: (tx) => (
{/* Verknüpft: die beiden Seiten wieder trennen. Sonst, wenn es überhaupt einer werden kann: zum Übertrag machen. Beides zugleich gibt es nie – eine verknüpfte Buchung IST schon ein Paar. */} {links[String(tx.id)] ? ( ) : tx.type !== "transfer" ? ( ) : null}
), }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups, formatDate, links, paarZu, vorschlag, abos, tags, tagById, t]); return (

{t("transactions.title")}

{accounts.length === 0 ? (
{t("transactions.needAccount")}
) : ( <>
setFilter({ ...filter, account_id: id != null ? String(id) : "" })} />
{[["expense", "filterExpenses"], ["income", "filterIncome"], ["uebertrag", "filterTransfers"]].map(([art, schluessel]) => ( ))}
tx.id} empty={t("transactions.empty")} columns={txnColumns} />
)} {uebertrag && ( setUebertrag(null)} onFertig={ladenBuchungen} /> )} {form && ( setForm(null)} footer={ <> } >
{TYPES.map((tp) => ( ))}
{form.type === "transfer" ? ( ) : ( )}
{crossCurrency && ( )}
setForm({ ...form, budget_period: label })} /> {savingsDest && (
{form.budget_as_expense && ( )}
)} {form.type !== "transfer" && (
)} {/* Die Kategorie sagt, in welche Schublade es gehoert. Die Notiz sagt, WAS es war – „Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk, und in drei Monaten weiss das niemand mehr. Freier Text, weil sich das nicht in Schubladen fassen laesst. */}