Files
certain/web/src/pages/Transactions.jsx
T
GuidoandClaude Opus 5 64b32cf8b8 fix: Tabelle passt wieder, und Uebertraege gehen auch aus der Buchungsliste
Die neue Kategoriespalte hatte 230 px Mindestbreite hinzugefuegt. Damit war die
Tabelle in einem schmalen Fenster breiter als ihre Karte und scrollte waagerecht
- von aussen sah das aus, als waere sie abgeschnitten. Alle Spalten sind jetzt
enger bemessen; bei 900 px Fensterbreite passt sie ohne Scrollen.

Ausserdem liess sich eine Buchung nur aus der Arbeitsliste zum Uebertrag
machen, nicht aus der Buchungsliste. Dort steht der Knopf jetzt auch - und zwar
statt des Loesen-Knopfes, denn beides zugleich gibt es nie: Eine verknuepfte
Buchung IST schon ein Paar.

Der Dialog dafuer ist eine eigene Komponente geworden. Die Nachfrage bei einem
geschaetzten Betrag - 409 abfangen, begruenden lassen, mit 'trotzdem' erneut
senden - ist zu leicht falsch nachgebaut, und jetzt gibt es sie nur einmal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:01:47 +02:00

513 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect";
import UebertragDialog from "../components/UebertragDialog";
import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien";
import { centsToInput, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"];
const SAVINGS_TYPES = ["sparkonto", "depot"];
function emptyForm() {
return {
type: "expense",
date: todayIso(),
account_id: "",
to_account_id: "",
category_id: "",
amount: "",
to_amount: "",
payee: "",
description: "",
budget_as_expense: true,
budget_category_id: "",
};
}
export default function Transactions() {
const { t } = useTranslation();
const { baseCurrency, formatDate } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [txns, setTxns] = useState([]);
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
const [links, setLinks] = useState({});
// Buchung, die gerade zum Übertrag gemacht wird.
const [uebertrag, setUebertrag] = useState(null);
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 {
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
// kein Feld an ihnen – deshalb auch ein eigener Abruf.
const [liste, verkn] = await Promise.all([
api.listTransactions({ account_id: filter.account_id, type: filter.type }),
api.transactionLinks().catch(() => ({})),
]);
setTxns(liste);
setLinks(verkn);
} catch (err) {
toast(err.message, "error");
}
}
async function kategorieSetzen(tx, categoryId) {
if (categoryId == null) return;
try {
const neu = await api.updateTransaction(tx.id, { category_id: Number(categoryId) });
// In der Liste ersetzen statt alles neu zu laden: Die Zeile ändert sich
// sofort, und die Bildlaufposition bleibt, wo sie war.
setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x)));
} catch (err) {
toast(err.message, "error");
}
}
async function loesen(tx) {
const link = links[String(tx.id)];
if (!link) return;
if (!(await confirm({
title: t("transfers.release"),
message: t("transfers.releaseConfirm"),
confirmLabel: t("transfers.release"),
}))) return;
try {
await api.releaseTransfer(link.link_id);
ladenBuchungen();
} 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;
const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type);
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
const sparenCatId = useMemo(() => {
for (const g of expenseGroups)
for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id);
return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : "";
}, [expenseGroups]);
// Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen.
useEffect(() => {
if (savingsDest && form && !form.budget_category_id) {
setForm((f) => (f && !f.budget_category_id ? { ...f, budget_category_id: sparenCatId } : f));
}
}, [savingsDest, sparenCatId]);
// 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 || "",
budget_as_expense: tx.budget_treatment !== "neutral",
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
});
}
const catOptions = useMemo(() => {
if (!form) return [];
const kind = form.type === "income" ? "income" : "expense";
return gruppenFuer(groups, 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;
if (savingsDest) {
body.budget_treatment = form.budget_as_expense ? "budgeted" : "neutral";
body.budget_category_id =
form.budget_as_expense && form.budget_category_id ? Number(form.budget_category_id) : null;
}
} 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") };
}
// Oben steht, WOFÜR die Zahlung war – danach sucht man in der Liste.
// Die Kategorie ist die Einordnung und steht klein darunter; das Symbol
// trägt sie ohnehin schon.
const cat = tx.category_id ? categoryById[tx.category_id] : null;
const zweck = tx.payee || tx.description;
return {
icon: cat?.icon || "more-horizontal",
color: cat?.color || "#6b7480",
title: zweck || cat?.name || t("transactions.uncategorized"),
// Der Verwendungszweck, falls er neben dem Empfänger noch etwas sagt.
// Die Kategorie steht in ihrer eigenen Spalte.
sub: tx.payee && tx.description && tx.description !== tx.payee
? tx.description : "",
};
}
const txnColumns = useMemo(() => [
{
key: "date", header: t("transactions.date"), width: 100,
sortValue: (tx) => tx.date,
render: (tx) => <span className="nowrap">{formatDate(tx.date)}</span>,
},
{
// Wofür die Zahlung war – danach sucht man in der Liste.
key: "text", header: t("transactions.subject"), grow: true, min: 150,
filterText: (tx) => rowLabel(tx).title,
sortValue: (tx) => rowLabel(tx).title,
render: (tx) => {
const l = rowLabel(tx);
return (
<div className="named">
<IconChip name={l.icon} color={l.color} size={26} />
<div className="named-text">
<div className="ellipsis">{l.title}</div>
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>}
{/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme.
Ohne das Gegenkonto sagt „Übertrag" nichts. */}
{links[String(tx.id)] && (
<div className="named-sub ellipsis uebertrag-marke">
<Icon name="arrow-left-right" size={12} />{" "}
{accountById[links[String(tx.id)].other_account_id]?.name
|| t("transfers.linked")}
</div>
)}
</div>
</div>
);
},
},
{
// Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem
// Text – gestapelt wurden die Zeilen hoch und die Spalte ausgefranst.
key: "category", header: t("transactions.category"), width: 170,
filterText: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
sortValue: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
render: (tx) => (
// Ein Übertrag bekommt keine Kategorie – er ordnet kein Geld ein.
tx.type === "transfer" ? (
<span className="muted">—</span>
) : (
<CategorySelect
groups={tx.type === "income" ? incomeGroups : expenseGroups}
value={tx.category_id}
onChange={(id) => kategorieSetzen(tx, id)}
placeholder={t("transactions.uncategorized")}
/>
)
),
},
{
key: "account", header: t("transactions.account"), width: 130,
filterText: (tx) => accountById[tx.account_id]?.name || "",
render: (tx) => accountById[tx.account_id]?.name || "",
},
{
key: "amount", header: t("transactions.amount"), align: "num", width: 140,
sortValue: (tx) => tx.amount_base_cents,
render: (tx) => {
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
);
},
},
{
key: "actions", header: "", width: 118, fixed: true,
render: (tx) => (
<div className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
{/* Verknüpft: die beiden Seiten wieder trennen. Sonst, wenn es
überhaupt einer werden kann: zum Übertrag machen. Beides zugleich
gibt es nie – eine verknüpfte Buchung IST schon ein Paar. */}
{links[String(tx.id)] ? (
<button className="btn sm ghost" title={t("transfers.release")}
onClick={() => loesen(tx)}>
<Icon name="arrow-left-right" size={14} />
</button>
) : tx.type !== "transfer" ? (
<button className="btn sm ghost" title={t("inbox.asTransfer")}
onClick={() => setUebertrag({
art: "buchung", id: tx.id, kontoId: tx.account_id,
betrag: tx.type === "income" ? tx.amount_cents : -tx.amount_cents,
waehrung: accountById[tx.account_id]?.currency,
text: tx.payee || tx.description, datum: tx.date,
})}>
<Icon name="arrow-left-right" size={14} />
</button>
) : null}
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</div>
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
formatDate, links, t]);
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">
<AccountSelect accounts={accounts} value={filter.account_id} allowNone
noneLabel={t("transactions.allAccounts")}
placeholder={t("transactions.allAccounts")} showCurrency={false}
minWidth={190}
onChange={(id) => setFilter({ ...filter, account_id: id != null ? String(id) : "" })} />
<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">
<DataTable
id="transactions-v2"
rows={txns}
getRowKey={(tx) => tx.id}
empty={t("transactions.empty")}
columns={txnColumns}
/>
</div>
</>
)}
{uebertrag && (
<UebertragDialog
vorgang={uebertrag}
accounts={accounts}
accountById={accountById}
baseCurrency={baseCurrency}
onClose={() => setUebertrag(null)}
onFertig={ladenBuchungen}
/>
)}
{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>
<AccountSelect accounts={accounts} value={form.account_id}
onChange={(id) => setForm({ ...form, account_id: id != null ? String(id) : "" })} />
</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>
<AccountSelect
accounts={accounts.filter((a) => String(a.id) !== form.account_id)}
value={form.to_account_id}
onChange={(id) => setForm({ ...form, to_account_id: id != null ? String(id) : "" })} />
</label>
) : (
<label>
<span>{t("transactions.category")}</span>
<CategorySelect
groups={catOptions}
value={form.category_id}
onChange={(id) => setForm({ ...form, category_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("transactions.uncategorized")}
placeholder={t("transactions.uncategorized")}
/>
</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>
{savingsDest && (
<div className="savings-opt">
<label className="checkline">
<input type="checkbox" checked={form.budget_as_expense}
onChange={(e) => setForm({ ...form, budget_as_expense: e.target.checked })} />
<span>{t("transactions.budgetAsSavings")}</span>
</label>
{form.budget_as_expense && (
<label>
<span>{t("transactions.budgetCategory")}</span>
<CategorySelect
groups={expenseGroups}
value={form.budget_category_id}
onChange={(id) => setForm({ ...form, budget_category_id: id != null ? String(id) : "" })}
placeholder={t("transactions.budgetCategory")}
/>
</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>
);
}