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