feat: Phase 1 – Konten, Kategorien, Transaktionen, EZB-Währungsumrechnung

Backend:
- Modelle + Migration 0002: accounts, account_identifiers, category_groups,
  categories, transactions, exchange_rates (alle owner-gescoped).
- EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum
  Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot
  (amount_eur_cents) eingefroren.
- Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung).
- Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien),
  transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates.
- Gruppierte Standard-Kategorien je Nutzer beim Anlegen.
- pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation.

Frontend:
- Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung,
  Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons).
- Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert.
- Live-Umrechnungs-Vorschau im Transfer-Formular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 18:59:24 +02:00
co-authored by Claude Opus 4.8
parent 2c795a57c8
commit 1b7107b9bd
35 changed files with 3116 additions and 6 deletions
+263
View File
@@ -0,0 +1,263 @@
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, 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: [],
};
}
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 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);
}
}
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 (
<div>
<div className="page-head">
<h1>{t("accounts.title")}</h1>
<button className="btn primary" onClick={openNew}>
<Icon name="plus" size={16} /> {t("accounts.new")}
</button>
</div>
{accounts.length === 0 ? (
<div className="card"><div className="empty-state">{t("accounts.empty")}</div></div>
) : (
<div className="card">
<div className="account-total">
<span className="muted">{t("accounts.total")}</span>
<strong>{formatMoney(total, baseCurrency)}</strong>
</div>
<div className="account-list">
{accounts.map((a) => (
<div className="account-row" key={a.id}>
<IconChip name={a.icon} color={a.color} />
<div className="named-text grow">
<div><strong>{a.name}</strong></div>
<div className="named-sub">
{t(`accountTypes.${a.type}`)} · {a.currency}
{a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`}
</div>
</div>
<Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
<div className="row-actions">
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(a)}>
<Icon name="trash" size={15} />
</button>
</div>
</div>
))}
</div>
</div>
)}
{form && (
<Modal
title={editing ? t("accounts.edit") : t("accounts.new")}
onClose={() => setForm(null)}
footer={
<>
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="account-form" type="submit" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="account-form" onSubmit={speichern}>
<label>
<span>{t("accounts.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
<span>{t("accounts.type")}</span>
<select value={form.type} onChange={(e) => setType(e.target.value)}>
{ACCOUNT_TYPES.map((tp) => (
<option key={tp} value={tp}>{t(`accountTypes.${tp}`)}</option>
))}
</select>
</label>
<label style={{ minWidth: 120 }}>
<span>{t("accounts.currency")}</span>
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
<option value="EUR">EUR</option>
<option value="CHF">CHF</option>
</select>
</label>
</div>
<div className="row">
<label className="grow">
<span>{t("accounts.openingBalance")} ({form.currency})</span>
<input inputMode="decimal" value={form.opening_balance}
onChange={(e) => setForm({ ...form, opening_balance: e.target.value })} placeholder="0,00" />
</label>
<label style={{ minWidth: 150 }}>
<span>{t("accounts.openingDate")}</span>
<input type="date" value={form.opening_date}
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
</label>
</div>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
onChange={(name) => setForm({ ...form, icon: name })} />
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
<IconChip name={form.icon} color={form.color} />
</div>
</label>
<label style={{ marginBottom: 6 }}><span>{t("accounts.identifiers")}</span></label>
{form.identifiers.map((ident, idx) => (
<div className="row" key={idx} style={{ marginBottom: 6 }}>
<select style={{ minWidth: 130 }} value={ident.kind}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, kind: e.target.value };
setForm({ ...form, identifiers: next });
}}>
<option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option>
</select>
<input className="grow" value={ident.value}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, value: e.target.value };
setForm({ ...form, identifiers: next });
}} />
<button type="button" className="btn sm ghost"
onClick={() => setForm({ ...form, identifiers: form.identifiers.filter((_, i) => i !== idx) })}>
<Icon name="trash" size={15} />
</button>
</div>
))}
<button type="button" className="btn sm"
onClick={() => setForm({ ...form, identifiers: [...form.identifiers, { kind: "iban", value: "" }] })}>
<Icon name="plus" size={15} /> {t("accounts.addIdentifier")}
</button>
</form>
</Modal>
)}
</div>
);
}