);
}
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={
<>
>
}
>
)}
);
}
// 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 (
{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 && (