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: "",
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(); } }} />
setTarget(e.target.value)}
style={{ flex: "0 0 160px", minWidth: 0, marginTop: 0 }}>
{t("transferRules.autoTarget")}
{accounts.map((a) => {a.name} )}
{t("categories.applyRules")}
{t("categories.applyRulesHint")}
{uebersprungen.length > 0 && (
{t("categories.applySkipped")}
{uebersprungen.map((m, i) => (
{m.datum} · {m.text} — {m.grund}
))}
)}
{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")}
entfernen(w.id)}>
))}
)}
);
}
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 || "",
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,
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");
}
}
const total = accounts.reduce((s, a) => s + a.balance_base_cents, 0);
return (
{t("accounts.title")}
{t("accounts.new")}
{accounts.length === 0 ? (
) : (
{t("accounts.total")}
{formatMoney(total, baseCurrency)}
{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 && (
saldoUebernehmen(a)}>
{t("accounts.adoptBankBalance")}
)}
) : 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. */}
saldoUebernehmen(a)}>
{t("accounts.adoptBankBalance")}
)}
)}
aufschluesseln(a)}>
{aufschluss[a.id] && (
)}
verschiebe(i, -1)}>
verschiebe(i, 1)}>
openEdit(a)}>
loeschen(a)}>
))}
)}
{form && (
setForm(null)}
footer={
<>
setForm(null)}>{t("common.cancel")}
{t("common.save")}
>
}
>
)}
);
}
// 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.
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)}
))}
)}
);
}