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 (