import { useEffect, 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 { 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: "",
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);
// 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([]);
const [editing, setEditing] = useState(null); // Account-Objekt oder null
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
const [busy, setBusy] = useState(false);
async function saldoUebernehmen(a) {
if (!(await confirm({
title: t("accounts.adoptBankBalance"),
message: t("accounts.adoptConfirm", {
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(); }, []);
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 || "",
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,
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 && (
{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")}
)}
)}
verschiebe(i, -1)}>
verschiebe(i, 1)}>
openEdit(a)}>
loeschen(a)}>
))}
)}
{form && (
setForm(null)}
footer={
<>
setForm(null)}>{t("common.cancel")}
{t("common.save")}
>
}
>
)}
);
}