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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user