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 IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker"; import CategorySelect from "../components/CategorySelect"; import { gruppenFuer } from "../kategorien"; import CurrencySelect from "../components/CurrencySelect"; import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format"; const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"]; const TYPE_ICON = { girokonto: "landmark", sparkonto: "piggy-bank", bargeld: "wallet", kreditkarte: "credit-card", depot: "trending-up", sonstiges: "wallet", }; function emptyForm() { return { name: "", type: "girokonto", currency: "EUR", icon: "landmark", color: "#3f51b5", opening_balance: "", opening_date: "", credit_limit: "", savings_category_id: "", identifiers: [], }; } // Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto // wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten. function TransferKeywords({ accounts }) { const { t } = useTranslation(); const toast = useToast(); const [words, setWords] = useState([]); const [input, setInput] = useState(""); const [amount, setAmount] = useState(""); const [target, setTarget] = useState(""); const [busy, setBusy] = useState(false); // Aufgeschlüsselter Saldo je Konto, sobald man danach fragt. const [aufschluss, setAufschluss] = useState({}); // Was beim nachträglichen Anwenden übersprungen wurde, samt Grund. const [uebersprungen, setUebersprungen] = useState([]); const confirm = useConfirm(); const accName = (id) => accounts.find((a) => a.id === id)?.name; // Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die // Regel gab –, liegt noch ohne Kategorie da; bei einer Bankanbindung dauernd. // Erst zeigen, dann tun: Ein Übertrag verschiebt Geld. async function nachtraeglichAnwenden() { setBusy(true); try { const vor = await api.applyRules(true); if (!vor.kategorien && !vor.uebertraege) { toast(t("categories.applyNothing")); setUebersprungen(vor.uebersprungen || []); return; } if (!(await confirm({ title: t("categories.applyRules"), message: t("categories.applyPreview", { kategorien: vor.kategorien, uebertraege: vor.uebertraege, }), confirmLabel: t("categories.applyRules"), }))) return; const r = await api.applyRules(false); toast(t("categories.applyDone", { kategorien: r.kategorien, uebertraege: r.uebertraege, })); setUebersprungen(r.uebersprungen || []); } catch (err) { toast(err.message, "error"); } finally { setBusy(false); } } async function laden() { try { setWords(await api.listTransferKeywords()); } catch (err) { toast(err.message, "error"); } } useEffect(() => { laden(); }, []); async function hinzufuegen() { const w = input.trim(); if (!w) return; setBusy(true); try { const body = { word: w }; const c = parseAmountToCents(amount); if (c) body.amount_cents = c; if (target) body.target_account_id = Number(target); await api.addTransferKeyword(body); setInput(""); setAmount(""); setTarget(""); await laden(); } catch (err) { toast(err.message, "error"); } finally { setBusy(false); } } async function entfernen(id) { try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); } catch (err) { toast(err.message, "error"); } } return (

{t("transferRules.title")}

{t("transferRules.hint")}

setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} /> setAmount(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
{t("categories.applyRulesHint")}
{uebersprungen.length > 0 && (
{t("categories.applySkipped")}
)} {words.length === 0 ? (
{t("transferRules.empty")}
) : (
{words.map((w) => ( {w.word} {w.amount_cents ? = {centsToInput(w.amount_cents)} : null} → {w.target_account_id ? (accName(w.target_account_id) || "?") : t("transferRules.autoTarget")} ))}
)}
); } export default function Accounts() { const { t } = useTranslation(); const { baseCurrency } = useSettings(); const confirm = useConfirm(); const toast = useToast(); const [accounts, setAccounts] = useState([]); // Nur fuer die Auswahl des Sparziels. Ausgaben-Gruppen, denn Sparen zaehlt im // Budget wie eine Ausgabe. const [groups, setGroups] = useState([]); const [editing, setEditing] = useState(null); // Account-Objekt oder null const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen) const [busy, setBusy] = useState(false); // Aufgeschlüsselter Saldo je Konto, sobald man danach fragt. const [aufschluss, setAufschluss] = useState({}); async function aufschluesseln(a) { if (aufschluss[a.id]) { setAufschluss((s) => ({ ...s, [a.id]: null })); return; } try { const daten = await api.balanceBreakdown(a.id); setAufschluss((s) => ({ ...s, [a.id]: daten })); } catch (e) { toast(e.message, "error"); } } async function saldoUebernehmen(a) { if (!(await confirm({ title: t("accounts.adoptBankBalance"), message: t(a.bank_balance_vergleichbar ? "accounts.adoptConfirm" : "accounts.adoptConfirmAvailable", { bank: formatMoney(a.bank_balance_cents, a.currency), diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency), }), confirmLabel: t("accounts.adoptBankBalance"), }))) return; try { await api.adoptBankBalance(a.id); toast(t("accounts.adopted")); laden(); } catch (e) { toast(e.message, "error"); } } async function laden() { try { setAccounts(await api.listAccounts()); } catch (err) { toast(err.message, "error"); } } useEffect(() => { laden(); }, []); useEffect(() => { api.listCategories().then(setGroups).catch(() => setGroups([])); }, []); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); // Eine Kategorie mit "Nicht in Auswertungen einrechnen" kann im Budget // nichts zeigen – der Haken an der Kategorie gewinnt. Das gehoert hierhin // gesagt und nicht erst gemerkt, wenn die Zahl fehlt. const sparzielNeutral = useMemo(() => { const id = Number(form?.savings_category_id); if (!id) return false; for (const g of groups) for (const c of g.categories || []) if (c.id === id) return !!c.excluded_from_stats; return false; }, [groups, form?.savings_category_id]); function openNew() { setEditing(null); setForm(emptyForm()); } function openEdit(acc) { setEditing(acc); setForm({ name: acc.name, type: acc.type, currency: acc.currency, icon: acc.icon, color: acc.color, opening_balance: centsToInput(acc.opening_balance_cents), opening_date: acc.opening_date || "", credit_limit: acc.credit_limit_cents != null ? centsToInput(acc.credit_limit_cents) : "", savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "", identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })), }); } function setType(type) { // Icon mitziehen, solange der Nutzer das Standard-Icon des alten Typs hatte. setForm((f) => ({ ...f, type, icon: f.icon === TYPE_ICON[f.type] ? TYPE_ICON[type] : f.icon, })); } async function speichern(e) { e.preventDefault(); setBusy(true); const body = { name: form.name.trim(), type: form.type, currency: form.currency, icon: form.icon, color: form.color, opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0, opening_date: form.opening_date || null, // Ausdruecklich null bei jedem anderen Typ: Wer eine Kreditkarte zum // Girokonto macht, soll den Rahmen nicht unsichtbar mitschleppen. credit_limit_cents: form.type === "kreditkarte" ? (parseAmountToCents(form.credit_limit) ?? null) : null, savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null, identifiers: form.identifiers .filter((i) => i.value.trim()) .map((i) => ({ kind: i.kind, value: i.value.trim() })), }; try { if (editing) await api.updateAccount(editing.id, body); else await api.createAccount(body); setForm(null); toast(t("settings.saved")); laden(); } catch (err) { toast(err.message, "error"); } finally { setBusy(false); } } // Reihenfolge per Pfeil: die Liste stand sonst in der Anlegereihenfolge. // Optimistisch umsortieren, damit die Liste nicht bei jedem Klick springt. async function verschiebe(index, richtung) { const ziel = index + richtung; if (ziel < 0 || ziel >= accounts.length) return; const neu = [...accounts]; [neu[index], neu[ziel]] = [neu[ziel], neu[index]]; setAccounts(neu); try { await api.setAccountOrder(neu.map((a) => a.id)); } catch (e) { toast(e.message, "error"); laden(); } } async function loeschen(acc) { if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; try { await api.deleteAccount(acc.id); laden(); } catch (err) { toast(err.message, "error"); } } // Archivierte bleiben draussen – das Backend rechnet ebenso // (balances.net_worth_cents, dashboard.py). Zwei Zahlen fuer dieselbe Sache // sind eine Zahl zu viel. const total = accounts .filter((a) => !a.is_archived) .reduce((s, a) => s + a.balance_base_cents, 0); return (

{t("accounts.title")}

{accounts.length === 0 ? (
{t("accounts.empty")}
) : (
{t("accounts.total")}
{accounts.map((a, i) => (
{a.name}
{t(`accountTypes.${a.type}`)} · {a.currency} {/* „1 Nr." sagte niemandem, was gemeint ist: die Kennungen, an denen Certain eine Auszugszeile diesem Konto zuordnet. */} {a.identifiers.length > 0 && ` · ${t("accounts.identShort", { count: a.identifiers.length })}`}
{/* Bei angebundenen Konten der Saldo, den die Bank fuehrt. Er ersetzt die eigene Rechnung nicht, er prueft sie: Eine Abweichung heisst, dass eine Buchung fehlt oder doppelt ist – sonst faellt das erst beim Vergleich von Hand auf. */} {a.bank_balance_cents != null && (
{/* Ein VERFÜGBARER Saldo enthält Vorgemerktes, das Certain bewusst nicht bucht. Daraus eine Abweichung zu machen, wäre eine Falschmeldung. */} {!a.bank_balance_vergleichbar ? ( {t("accounts.bankBalanceAvailable", { bank: formatMoney(a.bank_balance_cents, a.currency), })}{" "} {/* Nicht vergleichbar heisst nicht, dass man nicht angleichen dürfte – nur, dass Certain es nicht von sich aus als Abweichung meldet. Die Entscheidung bleibt beim Nutzer. */} {a.bank_balance_cents !== a.balance_cents && ( )} ) : a.bank_balance_cents === a.balance_cents ? ( {t("accounts.bankBalanceMatches")} {a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""} ) : ( {t("accounts.bankBalanceDiffers", { bank: formatMoney(a.bank_balance_cents, a.currency), diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency), })}{" "} {/* Die Bank kennt den wahren Stand. Uebernehmen legt die Differenz in den Anfangsbestand – Buchungen werden nicht angefasst. */} )}
)}
{aufschluss[a.id] && ( )}
))}
)} {form && ( setForm(null)} footer={ <> } >

{t("accounts.openingDateHint")}

{/* Nur bei Kreditkarten: An einem Girokonto gaebe es nichts zu begrenzen, und das Feld waere nur ein weiteres Bedienelement. */} {form.type === "kreditkarte" && ( <>

{t("accounts.creditLimitHint")}

)}

{t("accounts.savingsCategoryHint")}

{sparzielNeutral && (

{t("accounts.savingsCategoryNeutral")}

)} {form.identifiers.map((ident, idx) => (
{ const next = [...form.identifiers]; next[idx] = { ...ident, value: e.target.value }; setForm({ ...form, identifiers: next }); }} />
))} {form.identifiers.some((i) => i.kind === "text" && i.value.trim().length > 0 && i.value.trim().length < 6) && (

{t("accounts.shortIdentWarning")}

)}
)}
); } // Woraus sich ein Kontostand zusammensetzt. Stimmt er nicht, liegt es fast // immer an einer dieser Zahlen oder an einem Übertrag, der zu viel oder zu // wenig verschiebt – dann sieht man hier sofort, welcher Teil nicht passt. // Was von der Karte noch uebrig ist. // // Eine Kreditkarte ist kein Konto mit Geld darauf: Abbezahlt steht sie auf // null, jede Ausgabe drueckt sie ins Minus. Der Saldo allein sagt deshalb // nicht, wie viel noch geht – dafuer braucht es den Rahmen daneben. // // Gerechnet wird in der KONTOWAEHRUNG. Der umgerechnete Saldo // (balance_base_cents) passt nicht zu einem Rahmen, den die Bank in Franken // zugesagt hat. function Kreditrahmen({ account, t }) { const rahmen = account.credit_limit_cents; if (account.type !== "kreditkarte" || !rahmen) return null; // Ein positiver Saldo heisst ueberzahlt – dann ist der Rahmen voll da. const genutzt = Math.max(0, -account.balance_cents); const ueberzogen = genutzt > rahmen; const anteil = Math.min(100, Math.round((genutzt / rahmen) * 100)); return (
{ueberzogen ? t("accounts.creditLimitExceeded", { over: formatMoney(genutzt - rahmen, account.currency), limit: formatMoney(rahmen, account.currency), }) : t("accounts.creditLimitLeft", { left: formatMoney(rahmen - genutzt, account.currency), limit: formatMoney(rahmen, account.currency), })}
); } function Aufschluesselung({ daten, t }) { const { teile, uebertraege, bank, waehrung } = daten; const zeile = (schluessel, vorzeichen) => { const teil = teile[schluessel]; return (
{t(`accounts.part.${schluessel}`)} {teil.anzahl > 0 && ({teil.anzahl})} {vorzeichen}{formatMoney(teil.cents, waehrung)}
); }; return (
{zeile("anfangsbestand", "")} {/* Der Stichtag ist der haeufigste unsichtbare Grund dafuer, dass die Liste etwas anderes zeigt als der Kontostand. */} {teile.stichtag && teile.vor_stichtag.anzahl > 0 && (
{t("accounts.part.beforeCutoff", { datum: formatDate(teile.stichtag), n: teile.vor_stichtag.anzahl, })} {formatMoney(teile.vor_stichtag.cents, waehrung)}
)} {zeile("einnahmen", "+")} {zeile("ausgaben", "−")} {zeile("uebertraege_raus", "−")} {zeile("uebertraege_rein", "+")}
{t("accounts.part.saldo")} {formatMoney(teile.saldo.cents, waehrung)}
{bank.cents != null && (
{t(bank.vergleichbar ? "accounts.part.bankGebucht" : "accounts.part.bankVerfuegbar")} {formatMoney(bank.cents, waehrung)}
)} {uebertraege.length > 0 && (
{t("accounts.part.legs")}
{uebertraege.map((u) => (
{formatDate(u.datum)} · {u.von} → {u.nach} {u.text ? ` · ${u.text}` : ""} {u.raus ? "−" : "+"}{formatMoney(u.cents, waehrung)}
))}
)}
); }