import { useEffect, useMemo, 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 Money from "../components/Money"; import Modal from "../components/Modal"; import AccountSelect from "../components/AccountSelect"; import CategorySelect from "../components/CategorySelect"; import UebertragDialog from "../components/UebertragDialog"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; import { centsToInput, 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: "", budget_as_expense: true, budget_category_id: "", }; } export default function Transactions() { const { t } = useTranslation(); const { baseCurrency, formatDate } = 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({}); // Buchung, die gerade zum Übertrag gemacht wird. const [uebertrag, setUebertrag] = useState(null); const [accounts, setAccounts] = useState([]); const [groups, setGroups] = useState([]); const [filter, setFilter] = useState({ account_id: "", type: "" }); 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 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] = await Promise.all([api.listAccounts(), api.listCategories()]); setAccounts(accs); setGroups(cats); } 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] = await Promise.all([ api.listTransactions({ account_id: filter.account_id, type: filter.type }), api.transactionLinks().catch(() => ({})), // Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts // verknüpft werden. api.transferSuggestions().catch(() => ({ vorschlaege: [] })), ]); setTxns(liste); setLinks(verkn); 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 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"); } } 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(); }, []); useEffect(() => { ladenBuchungen(); }, [filter.account_id, filter.type]); 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; const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type); // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const sparenCatId = useMemo(() => { 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]); // 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 || "", budget_as_expense: tx.budget_treatment !== "neutral", budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "", }); } 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, }; 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"); } } function rowLabel(tx) { 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, filterText: (tx) => rowLabel(tx).title, sortValue: (tx) => rowLabel(tx).title, render: (tx) => { const l = rowLabel(tx); return (