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
+340
View File
@@ -0,0 +1,340 @@
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 { centsToInput, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"];
function emptyForm() {
return {
type: "expense",
date: todayIso(),
account_id: "",
to_account_id: "",
category_id: "",
amount: "",
to_amount: "",
payee: "",
description: "",
};
}
export default function Transactions() {
const { t } = useTranslation();
const { baseCurrency, formatDate } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [txns, setTxns] = useState([]);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [filter, setFilter] = useState({ account_id: "", type: "" });
const [form, setForm] = useState(null);
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
const categoryById = useMemo(() => {
const m = {};
for (const g of groups) for (const c of g.categories) m[c.id] = c;
return m;
}, [groups]);
async function ladenStamm() {
try {
const [accs, cats] = await Promise.all([api.listAccounts(), api.listCategories()]);
setAccounts(accs);
setGroups(cats);
} catch (err) {
toast(err.message, "error");
}
}
async function ladenBuchungen() {
try {
setTxns(await api.listTransactions({ account_id: filter.account_id, type: filter.type }));
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { ladenStamm(); }, []);
useEffect(() => { ladenBuchungen(); }, [filter.account_id, filter.type]);
const fromAcc = form && accountById[Number(form.account_id)];
const toAcc = form && accountById[Number(form.to_account_id)];
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
// Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen.
useEffect(() => {
if (!crossCurrency) return;
const cents = parseAmountToCents(form.amount);
if (!cents || form.to_amount) return;
let abbruch = false;
api.convertRate(cents, fromAcc.currency, toAcc.currency, form.date)
.then((r) => {
if (!abbruch && r.converted_cents != null) {
setForm((f) => (f && !f.to_amount ? { ...f, to_amount: centsToInput(r.converted_cents) } : f));
}
})
.catch(() => {});
return () => { abbruch = true; };
}, [crossCurrency, form?.amount, form?.account_id, form?.to_account_id, form?.date]);
function openNew() {
setEditing(null);
setForm({ ...emptyForm(), account_id: accounts[0]?.id ? String(accounts[0].id) : "" });
}
function openEdit(tx) {
setEditing(tx);
setForm({
type: tx.type,
date: tx.date,
account_id: String(tx.account_id),
to_account_id: tx.to_account_id ? String(tx.to_account_id) : "",
category_id: tx.category_id ? String(tx.category_id) : "",
amount: centsToInput(tx.amount_cents),
to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "",
payee: tx.payee || "",
description: tx.description || "",
});
}
const catOptions = useMemo(() => {
if (!form) return [];
const kind = form.type === "income" ? "income" : "expense";
return groups.filter((g) => g.kind === kind);
}, [form, groups]);
async function speichern(e) {
e.preventDefault();
const cents = parseAmountToCents(form.amount);
if (!cents || cents <= 0) { toast(t("transactions.amountRequired"), "error"); return; }
if (!form.account_id) { toast(t("transactions.accountRequired"), "error"); return; }
setBusy(true);
const body = {
type: form.type,
date: form.date,
account_id: Number(form.account_id),
amount_cents: cents,
payee: form.payee.trim() || null,
description: form.description.trim() || null,
};
if (form.type === "transfer") {
body.to_account_id = Number(form.to_account_id);
const toCents = parseAmountToCents(form.to_amount);
if (toCents) body.to_amount_cents = toCents;
} else {
body.category_id = form.category_id ? Number(form.category_id) : null;
}
try {
if (editing) await api.updateTransaction(editing.id, body);
else await api.createTransaction(body);
setForm(null);
toast(t("settings.saved"));
ladenBuchungen();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function loeschen(tx) {
if (!(await confirm({ title: t("common.delete"), message: t("transactions.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteTransaction(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); }
}
function rowLabel(tx) {
if (tx.type === "transfer") {
const from = accountById[tx.account_id]?.name || "?";
const to = accountById[tx.to_account_id]?.name || "?";
return { icon: "arrow-left-right", color: "#6b7480", title: `${from} → ${to}`, sub: tx.description || t("transactions.transfer") };
}
const cat = tx.category_id ? categoryById[tx.category_id] : null;
return {
icon: cat?.icon || "more-horizontal",
color: cat?.color || "#6b7480",
title: cat?.name || t("transactions.uncategorized"),
sub: tx.payee || tx.description || "",
};
}
return (
<div>
<div className="page-head">
<h1>{t("transactions.title")}</h1>
<button className="btn primary" onClick={openNew} disabled={accounts.length === 0}>
<Icon name="plus" size={16} /> {t("transactions.new")}
</button>
</div>
{accounts.length === 0 ? (
<div className="card"><div className="empty-state">{t("transactions.needAccount")}</div></div>
) : (
<>
<div className="card filter-bar">
<select value={filter.account_id} onChange={(e) => setFilter({ ...filter, account_id: e.target.value })}>
<option value="">{t("transactions.allAccounts")}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}>
<option value="">{t("transactions.allTypes")}</option>
<option value="expense">{t("transactions.expense")}</option>
<option value="income">{t("transactions.income")}</option>
<option value="transfer">{t("transactions.transfer")}</option>
</select>
</div>
<div className="card">
{txns.length === 0 ? (
<div className="empty-state">{t("transactions.empty")}</div>
) : (
<table className="table">
<thead>
<tr>
<th>{t("transactions.date")}</th>
<th>{t("transactions.category")}</th>
<th>{t("transactions.account")}</th>
<th style={{ textAlign: "right" }}>{t("transactions.amount")}</th>
<th></th>
</tr>
</thead>
<tbody>
{txns.map((tx) => {
const l = rowLabel(tx);
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<tr key={tx.id}>
<td className="nowrap">{formatDate(tx.date)}</td>
<td>
<div className="named">
<IconChip name={l.icon} color={l.color} size={28} />
<div className="named-text">
<div>{l.title}</div>
{l.sub && <div className="named-sub">{l.sub}</div>}
</div>
</div>
</td>
<td className="nowrap">{acc?.name}</td>
<td style={{ textAlign: "right" }}>
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
</td>
<td className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</>
)}
{form && (
<Modal
title={editing ? t("transactions.edit") : t("transactions.new")}
onClose={() => setForm(null)}
footer={
<>
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="txn-form" type="submit" disabled={busy}>{t("common.save")}</button>
</>
}
>
<form id="txn-form" onSubmit={speichern}>
<div className="seg">
{TYPES.map((tp) => (
<button key={tp} type="button"
className={`seg-btn${form.type === tp ? " active" : ""}`}
onClick={() => setForm({ ...form, type: tp, category_id: "", to_account_id: "", to_amount: "" })}>
{t(`transactions.${tp}`)}
</button>
))}
</div>
<div className="row">
<label className="grow">
<span>{form.type === "transfer" ? t("transactions.fromAccount") : t("transactions.account")}</span>
<select value={form.account_id} onChange={(e) => setForm({ ...form, account_id: e.target.value })} required>
<option value="" disabled>—</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
</select>
</label>
<label style={{ minWidth: 150 }}>
<span>{t("transactions.date")}</span>
<input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} required />
</label>
</div>
{form.type === "transfer" ? (
<label>
<span>{t("transactions.toAccount")}</span>
<select value={form.to_account_id} onChange={(e) => setForm({ ...form, to_account_id: e.target.value })} required>
<option value="" disabled>—</option>
{accounts.filter((a) => String(a.id) !== form.account_id).map((a) => (
<option key={a.id} value={a.id}>{a.name} ({a.currency})</option>
))}
</select>
</label>
) : (
<label>
<span>{t("transactions.category")}</span>
<select value={form.category_id} onChange={(e) => setForm({ ...form, category_id: e.target.value })}>
<option value="">{t("transactions.uncategorized")}</option>
{catOptions.map((g) => (
<optgroup key={g.id} label={g.name}>
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</optgroup>
))}
</select>
</label>
)}
<div className="row">
<label className="grow">
<span>{t("transactions.amount")} {fromAcc ? `(${fromAcc.currency})` : ""}</span>
<input inputMode="decimal" value={form.amount} placeholder="0,00"
onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
</label>
{crossCurrency && (
<label className="grow">
<span>{t("transactions.toAmount")} ({toAcc.currency})</span>
<input inputMode="decimal" value={form.to_amount} placeholder="0,00"
onChange={(e) => setForm({ ...form, to_amount: e.target.value })} />
</label>
)}
</div>
{form.type !== "transfer" && (
<div className="row">
<label className="grow">
<span>{t("transactions.payee")}</span>
<input value={form.payee} onChange={(e) => setForm({ ...form, payee: e.target.value })} />
</label>
</div>
)}
<label>
<span>{t("transactions.description")}</span>
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</label>
</form>
</Modal>
)}
</div>
);
}