Files
certain/web/src/pages/Transactions.jsx
T
GuidoandClaude Opus 5 7fe2678192 feat: Ein verknuepftes Paar steht als EINE Zeile in der Buchungsliste
Ein Uebertrag ist eine Bewegung, keine zwei. In der Liste standen aber beide
Seiten untereinander, jede mit der Marke des Gegenkontos – man musste sie
selbst zusammensetzen, und es sah aus wie eine Dublette.

Liegen beide Seiten in der Liste, wird die Zugangs-Seite ausgeblendet und die
Abgangs-Seite zeigt "Revolut CHF → Sparen Pocket" – so wie ein echter
Uebertrag. Ueber zwei Waehrungen stehen beide Betraege untereinander
("CHF 400.00" / "→ 425,32 €"); nebeneinander passen sie nicht in die Spalte.

In der Datenbank aendert sich nichts: Beide Buchungen bleiben stehen, mit ihren
Import-Schluesseln und ihrer Stapel-Zugehoerigkeit. Das ist die tragende
Eigenschaft der Verknuepfung, und sie bleibt.

Ist nur eine Seite geladen – etwa weil nach einem Konto gefiltert wird –,
bleibt sie einzeln stehen und traegt weiter die Marke mit dem Gegenkonto.
Sonst verschwaende dort eine Buchung aus der Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:18:22 +02:00

635 lines
26 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, formatMoney, 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({});
// Und je Buchungs-ID die Gegenseite, die es sein KÖNNTE – noch unbestätigt.
const [vorschlag, setVorschlag] = 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, vorschlaege] = await Promise.all([
api.listTransactions({ account_id: filter.account_id, type: filter.type }),
api.transactionLinks().catch(() => ({})),
// Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts
// verknüpft werden.
api.transferSuggestions().catch(() => ({ vorschlaege: [] })),
]);
setTxns(liste);
setLinks(verkn);
const paare = {};
for (const v of vorschlaege.vorschlaege || []) {
paare[v.a.id] = v.b;
paare[v.b.id] = v.a;
}
setVorschlag(paare);
} 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 verbinden(tx) {
const gegen = vorschlag[tx.id];
if (!gegen) return;
try {
await api.connectTransfer(tx.id, gegen.id);
ladenBuchungen();
} catch (err) {
toast(err.message, "error");
}
}
async function nichtVerbinden(tx) {
const gegen = vorschlag[tx.id];
if (!gegen) return;
try {
// Ablehnen wird gemerkt, sonst fragt Certain bei jedem Laden erneut.
await api.ignoreTransfer(tx.id, gegen.id);
ladenBuchungen();
} 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"); }
}
// Ein verknüpftes Paar ist EINE Bewegung, keine zwei. Liegen beide Seiten in
// der Liste, wird die Zugangs-Seite ausgeblendet und die Abgangs-Seite zeigt
// „von → nach" – so wie ein echter Übertrag. Ist nur eine Seite geladen
// (etwa weil nach einem Konto gefiltert wird), bleibt sie einzeln stehen;
// dann trägt sie weiter die Marke mit dem Gegenkonto.
const { zeilen, paarZu } = useMemo(() => {
const vorhanden = new Set(txns.map((t) => t.id));
const paare = {};
const sichtbar = [];
for (const tx of txns) {
const link = links[String(tx.id)];
const gegenseiteDa = link && vorhanden.has(link.other_id);
if (gegenseiteDa && link.richtung === "rein") continue; // von der anderen gezeigt
if (gegenseiteDa) paare[tx.id] = txns.find((x) => x.id === link.other_id);
sichtbar.push(tx);
}
return { zeilen: sichtbar, paarZu: paare };
}, [txns, links]);
function rowLabel(tx) {
const gegen = paarZu[tx.id];
if (gegen) {
const von = accountById[tx.account_id]?.name || "?";
const nach = accountById[gegen.account_id]?.name || "?";
return {
icon: "arrow-left-right", color: "#6b7480",
title: `${von} → ${nach}`,
sub: tx.payee || tx.description || t("transactions.transfer"),
};
}
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: 190,
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">
{/* Herkunft NEBEN dem Titel, nicht darunter: Die zweite Zeile
gehört dem Verwendungszweck, und alles Neue dorthin zu
stapeln macht die Zeilen hoch und den Blick eng. */}
<div className="titelzeile">
<span className="ellipsis">{l.title}</span>
<span className="badge herkunft">{t(`todo.from.${tx.herkunft}`, {
defaultValue: tx.herkunft,
})}</span>
</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. */}
{/* Bei einer Paar-Zeile steht das Gegenkonto schon im Titel –
die Marke waere dort nur eine Wiederholung. */}
{links[String(tx.id)] && !paarZu[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>
)}
{/* Nachträglich erkannt: Auf einem anderen Konto steht der
gleiche Betrag am selben Tag in die andere Richtung. Fragen
statt tun – und die Antwort merken. */}
{!links[String(tx.id)] && vorschlag[tx.id] && (
<div className="uebertrag-frage">
<Icon name="arrow-left-right" size={12} />
<span className="ellipsis">
{t("transfers.question", {
konto: accountById[vorschlag[tx.id].account_id]?.name || "?",
})}
</span>
<button type="button" className="btn sm"
onClick={() => verbinden(tx)}>
{t("transfers.connect")}
</button>
<button type="button" className="btn sm ghost"
onClick={() => nichtVerbinden(tx)}>
{t("transfers.ignore")}
</button>
</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. Eine
// VERKNÜPFTE Buchung ebenso wenig: Sie gehört zu einem Übertrag und
// zählt in keiner Auswertung, eine Kategorie bewirkt dort nichts.
tx.type === "transfer" || links[String(tx.id)] ? (
<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 gegen = paarZu[tx.id];
if (gegen) {
// Ein Paar hat kein Vorzeichen – es verschiebt. Bei verschiedenen
// Währungen stehen beide Beträge, sonst verschwände einer.
const zielKonto = accountById[gegen.account_id];
const andereWaehrung = zielKonto?.currency !== acc?.currency;
if (!andereWaehrung) {
return (
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} />
);
}
// Über zwei Währungen gehören beide Beträge dazu – nebeneinander
// passen sie nicht in die Spalte, untereinander schon. Dieselbe
// Anordnung nutzt der umgerechnete Wert ohnehin.
return (
<span className="money">
<span className="amount">
{formatMoney(tx.amount_cents, acc?.currency || "EUR")}
</span>
<span className="amount-converted">
→ {formatMoney(gegen.amount_cents, zielKonto?.currency || "EUR")}
</span>
</span>
);
}
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, paarZu, vorschlag, 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={zeilen}
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>
);
}