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
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Modal from "../components/Modal";
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
export default function Categories() {
const { t } = useTranslation();
const confirm = useConfirm();
const toast = useToast();
const [groups, setGroups] = useState([]);
const [modal, setModal] = useState(null); // {type:'group'|'category', editing, groupId, form}
async function laden() {
try {
setGroups(await api.listCategories());
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { laden(); }, []);
function openGroup(editing = null) {
setModal({
type: "group",
editing,
form: editing
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
});
}
function openCategory(groupId, editing = null) {
setModal({
type: "category",
editing,
groupId,
form: editing
? { name: editing.name, icon: editing.icon, color: editing.color }
: { name: "", icon: "more-horizontal", color: "#6b7480" },
});
}
async function speichern(e) {
e.preventDefault();
const { type, editing, groupId, form } = modal;
try {
if (type === "group") {
if (editing) await api.updateGroup(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color });
else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color });
} else {
if (editing) await api.updateCategory(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color });
else await api.createCategory({ group_id: groupId, name: form.name.trim(), icon: form.icon, color: form.color });
}
setModal(null);
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function loeschenGruppe(g) {
if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteGroupConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteGroup(g.id); laden(); } catch (err) { toast(err.message, "error"); }
}
async function loeschenKategorie(c) {
if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteCategory(c.id); laden(); } catch (err) { toast(err.message, "error"); }
}
return (
<div>
<div className="page-head">
<h1>{t("categories.title")}</h1>
<button className="btn primary" onClick={() => openGroup()}>
<Icon name="plus" size={16} /> {t("categories.newGroup")}
</button>
</div>
{groups.map((g) => (
<div className="card cat-group" key={g.id}>
<div className="cat-group-head">
<IconChip name={g.icon} color={g.color} />
<h2 className="grow">{g.name}</h2>
<span className={`badge ${g.kind === "income" ? "ok" : ""}`}>
{g.kind === "income" ? t("categories.income") : t("categories.expense")}
</span>
<button className="btn sm ghost" title={t("categories.newCategory")} onClick={() => openCategory(g.id)}>
<Icon name="plus" size={15} />
</button>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openGroup(g)}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenGruppe(g)}>
<Icon name="trash" size={15} />
</button>
</div>
<div className="cat-list">
{g.categories.map((c) => (
<div className="cat-row" key={c.id}>
<IconChip name={c.icon} color={c.color} size={28} />
<span className="grow">{c.name}</span>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenKategorie(c)}>
<Icon name="trash" size={14} />
</button>
</div>
))}
{g.categories.length === 0 && <div className="muted small">{t("categories.emptyGroup")}</div>}
</div>
</div>
))}
{modal && (
<Modal
title={
modal.type === "group"
? (modal.editing ? t("categories.editGroup") : t("categories.newGroup"))
: (modal.editing ? t("categories.editCategory") : t("categories.newCategory"))
}
onClose={() => setModal(null)}
footer={
<>
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="cat-form" type="submit">{t("common.save")}</button>
</>
}
>
<form id="cat-form" onSubmit={speichern}>
<label>
<span>{t("categories.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={modal.form.name}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })} required />
</label>
{modal.type === "group" && !modal.editing && (
<label>
<span>{t("categories.kind")}</span>
<select value={modal.form.kind}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, kind: e.target.value } })}>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
</select>
</label>
)}
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={CATEGORY_ICONS} value={modal.form.icon} color={modal.form.color}
onChange={(name) => setModal({ ...modal, form: { ...modal.form, icon: name } })} />
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input type="color" value={modal.form.color}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })} />
<IconChip name={modal.form.icon} color={modal.form.color} />
</div>
</label>
</form>
</Modal>
)}
</div>
);
}