Files
certain/web/src/pages/Transactions.jsx
T
ScarriffleandClaude Opus 5 15b863ff5e fix: Bearbeiten und Loeschen stehen wieder sichtbar in jeder Zeile
Mit Notiz und Tags ist die Buchungsliste breiter als der Bildschirm geworden.
Die Knopfspalte scrollte damit einfach aus dem Bild - und weil sie in einer
gespeicherten Spaltenreihenfolge auch noch mittendrin stand, war sie gar
nicht mehr zu finden. "fixed" hiess bisher nur "nicht verschiebbar", nicht
"bleibt stehen".

Angeheftete Spalten (neu: pin) kleben jetzt am rechten Rand und ruecken
immer ans Ende, egal was gespeichert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 11:48:07 +02:00

863 lines
36 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, useRef, 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 EditableText from "../components/EditableText";
import Money from "../components/Money";
import TagSelect from "../components/TagSelect";
import Modal from "../components/Modal";
import BudgetZeitraum from "../components/BudgetZeitraum";
import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect";
import AboMarke from "../components/AboMarke";
import UebertragDialog from "../components/UebertragDialog";
import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien";
import { centsToInput, formatMonthLabel, formatMonthShort, 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: "",
notes: "",
tag_ids: [],
budget_as_expense: true,
budget_category_id: "",
// Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es.
budget_period: null,
};
}
export default function Transactions() {
const { t, i18n } = useTranslation();
const { baseCurrency, formatDate, budget_start_day: startTag } = 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({});
const [abos, setAbos] = useState([]);
// Buchung, die gerade zum Übertrag gemacht wird.
const [uebertrag, setUebertrag] = useState(null);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [tags, setTags] = useState([]);
// Drei Haken statt eines Typs: Man will oft zwei Arten gleichzeitig sehen
// und eine dritte weglassen, und genau das ging mit einer Einfachauswahl nicht.
const [filter, setFilter] = useState({
account_id: "", arten: { expense: true, income: true, uebertrag: true },
});
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 tagById = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
// Die Tags einer Zeile als Datensaetze – fuer die Anzeige und fuer den
// Spaltenfilter, der mit Namen arbeitet und nicht mit IDs.
const tagsVon = (tx) => (tx.tag_ids || []).map((id) => tagById[id]).filter(Boolean);
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, tagListe] = await Promise.all([
api.listAccounts(), api.listCategories(), api.listTags(),
]);
setAccounts(accs);
setGroups(cats);
setTags(tagListe);
} 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, aboListe] = await Promise.all([
api.listTransactions({ account_id: filter.account_id }),
api.transactionLinks().catch(() => ({})),
// Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts
// verknüpft werden.
api.transferSuggestions().catch((e) => {
// Stillschweigen waere hier das Schlimmste: Die Liste saehe aus, als
// gaebe es nichts zu verbinden.
toast(t("transfers.suggestionsFailed", { fehler: e.message }), "error");
return { vorschlaege: [] };
}),
api.listSubscriptions().catch(() => ({ subscriptions: [] })),
]);
setTxns(liste);
setLinks(verkn);
setAbos(aboListe.subscriptions || []);
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 notizSetzen(tx, text) {
try {
const neu = await api.updateTransaction(tx.id, { notes: text || null });
setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x)));
} catch (err) {
toast(err.message, "error");
}
}
// Je Zeile eine Warteschlange. Die Auswahl bleibt nach einem Klick offen –
// man vergibt selten genau einen Tag –, also kommen zwei Klicks schnell
// hintereinander. Ohne die Reihung ueberholten sich die beiden Anfragen, und
// welche zuletzt ankam, entschied: Der erste Tag fiel wieder ab.
const tagLauf = useRef({});
function tagsSetzen(tx, ids) {
const vorher = tx.tag_ids || [];
// Sofort anzeigen, nicht erst nach der Antwort: Sonst rechnet der zweite
// Klick noch mit dem Stand VOR dem ersten und nimmt ihn zurueck.
setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: ids } : x)));
const lauf = (tagLauf.current[tx.id] || Promise.resolve())
.catch(() => {})
.then(() => api.updateTransaction(tx.id, { tag_ids: ids.map(Number) }))
// Die Zeile ersetzen statt alles neu zu laden, sonst springt die
// Bildlaufposition bei jedem Klick nach oben.
.then((neu) => setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x))))
.catch((err) => {
setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: vorher } : x)));
toast(err.message, "error");
});
tagLauf.current[tx.id] = lauf;
return lauf;
}
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");
}
}
// Zuordnen heisst zugleich: lernen. Musste man von Hand ran, hat der
// Erkennungstext des Vertrags nicht gepasst; der Server uebernimmt den
// Empfaenger dieser Buchung, damit es beim naechsten Mal von selbst faellt.
async function aboSetzen(tx, subId) {
try {
if (subId) await api.assignSubscription(subId, tx.id);
else await api.unassignSubscription(tx.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(); }, []);
// Nur das Konto geht an den Server; nach Art wird im Browser gefiltert.
useEffect(() => { ladenBuchungen(); }, [filter.account_id]);
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;
// Sparziel ist das Konto, wenn es eine Spar-Kategorie traegt – oder, als
// Vorgabe, wenn sein Typ dafuer steht. Am Typ allein laesst es sich nicht
// ablesen: Ein Revolut-Pocket kommt als ganz normales Girokonto herein.
const savingsDest = form?.type === "transfer" && toAcc
&& (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type));
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
// Eine namenlose Gruppe: CategorySelect zeigt dann keine Kopfzeile.
// Alphabetisch, nicht in Anlagereihenfolge – bei zwanzig Vertraegen sucht
// man einen bestimmten, und die Reihenfolge der Eingabe kennt niemand.
const aboGruppe = useMemo(() => [{
id: 0, name: null,
categories: [...abos].sort((a, b) => a.name.localeCompare(b.name, i18n.language)),
}], [abos, i18n.language]);
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
const sparenCatId = useMemo(() => {
// Was am Zielkonto steht, schlaegt jede Vermutung: Dort hat es jemand
// ausdruecklich gesagt.
if (toAcc?.savings_category_id != null) return String(toAcc.savings_category_id);
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, toAcc]);
// 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 || "",
notes: tx.notes || "",
tag_ids: tx.tag_ids || [],
budget_as_expense: tx.budget_treatment !== "neutral",
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
// Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst:
// aus dem Datum – und wandert mit, wenn man das Datum noch aendert.
budget_period: tx.budget_period_manuell ? tx.budget_period : null,
});
}
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,
notes: form.notes.trim() || null,
tag_ids: form.tag_ids || [],
budget_period: form.budget_period || 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);
}
// Eine verknuepfte Buchung ist weder Ausgabe noch Einnahme – sie verschiebt
// nur Geld (siehe transfers.linkedHint). Nach ihrem rohen `type` zu filtern
// zeigte sie unter „Einnahmen", wo sie niemand sucht.
const art = (t) => (t.type === "transfer" || links[String(t.id)] ? "uebertrag" : t.type);
return { zeilen: sichtbar.filter((t) => filter.arten[art(t)]), paarZu: paare };
}, [txns, links, filter.arten]);
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", uebertrag: true,
title: `${von} → ${nach}`,
sub: tx.payee || tx.description || t("transactions.transfer"),
};
}
// Verknuepft, aber die Gegenseite ist nicht in der Liste (gefiltert oder
// auf einer anderen Seite). Dieselbe Darstellung wie beim Paar: Oben steht,
// zwischen WELCHEN Konten das Geld lief, und nicht ein Empfaengertext, den
// die Bank vergeben hat.
//
// Vorher stand hier der Rohtext als Titel und die Verknuepfung nur klein
// darunter – „To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c" sah aus wie
// eine Zahlung an jemanden.
const link = links[String(tx.id)];
if (link) {
const eigen = accountById[tx.account_id]?.name || "?";
const anderes = accountById[link.other_account_id]?.name
|| t("transfers.linked");
return {
icon: "arrow-left-right", color: "#6b7480", uebertrag: true,
title: link.richtung === "raus"
? `${eigen} → ${anderes}` : `${anderes} → ${eigen}`,
sub: tx.payee || tx.description || "",
};
}
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,
// Auch die Notiz durchsuchbar: Wer sie schreibt, will spaeter danach
// suchen koennen („Geschenk", „beruflich").
filterText: (tx) => [rowLabel(tx).title, tx.notes].filter(Boolean).join(" "),
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>
{/* Ein Uebertrag ist keine Ausgabe und keine Einnahme. Das
gehoert in die erste Zeile, nicht in eine Fussnote. */}
{l.uebertrag && (
<span className="badge accent herkunft"
title={t("transfers.linkedHint")}>
<Icon name="arrow-left-right" size={11} />
{t("transactions.transfer")}
</span>
)}
<span className="badge herkunft">{t(`todo.from.${tx.herkunft}`, {
defaultValue: tx.herkunft,
})}</span>
{/* Nur der Ausnahmefall wird gezeigt: Steht hier nichts, gilt
das Datum – und das steht ohnehin in seiner eigenen
Spalte. */}
{tx.budget_period_manuell && (
<span className="badge accent herkunft"
title={`${t("transactions.budgetPeriod")}: ${formatMonthLabel(tx.budget_period, i18n.language)} (${t("transactions.budgetPeriodManual")})`}>
<Icon name="calendar" size={11} />
{formatMonthShort(tx.budget_period, i18n.language)}
</span>
)}
</div>
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>}
{/* Die fruehere Marke mit dem Gegenkonto ist entfallen: Das
Gegenkonto steht jetzt im Titel, wo man es sucht. */}
{/* 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>
);
},
},
{
// Die Notiz stand bisher nur im Bearbeiten-Dialog. Wer sie schreibt,
// will sie aber spaeter sehen, ohne jede Zeile aufzuklappen.
key: "notes", header: t("transactions.notes"), width: 180,
filterText: (tx) => tx.notes || "",
render: (tx) => (
<EditableText
value={tx.notes}
placeholder={t("transactions.notesCell")}
onSave={(text) => notizSetzen(tx, text)}
/>
),
},
{
// 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")}
/>
)
),
},
{
// Welcher Vertrag diese Zahlung ist. Dieselbe Form wie die Kategorie
// daneben: Die Auswahl zeigt den Stand und aendert ihn, leer loest.
key: "abo", header: t("abos.column"), width: 160,
filterText: (tx) => abos.find((a) => a.id === tx.subscription_id)?.name || "",
render: (tx) => (
// Ein Uebertrag ist keine Abo-Zahlung – er verschiebt nur Geld.
tx.type === "transfer" || links[String(tx.id)] ? (
<span className="muted">—</span>
) : (
// Dieselbe Auswahl wie bei der Kategorie – nur flach (die Vertraege
// haben keine Gruppen) und mit dem Markenlogo statt eines Symbols.
<CategorySelect
groups={aboGruppe}
value={tx.subscription_id}
onChange={(id) => aboSetzen(tx, id)}
renderMarke={(a, size) => <AboMarke abo={a} size={size} />}
searchPlaceholder={t("abos.searchPlaceholder")}
allowNone
noneLabel={t("abos.noneAssigned")}
placeholder={t("abos.noneAssigned")}
/>
)
),
},
{
// filterValues statt filterText: Eine Zeile hat MEHRERE Tags, und die
// DataTable kann das – sie vergleicht dann gegen jeden einzelnen Wert.
key: "tags", header: t("tags.column"), width: 170,
filterValues: (tx) => tagsVon(tx).map((x) => x.name),
render: (tx) => (
<TagSelect
tags={tags}
value={tx.tag_ids || []}
onChange={(ids) => tagsSetzen(tx, ids)}
placeholder={t("tags.none")}
/>
),
},
{
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} />
);
},
},
{
// Angeheftet: Seit Notiz und Tags dazugekommen sind, ist die Tabelle
// breiter als der Bildschirm – ohne das scrollten Bearbeiten und
// Loeschen einfach aus dem Bild.
key: "actions", header: "", width: 118, fixed: true, pin: "right",
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, abos, tags, tagById, 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) : "" })} />
<div className="art-filter">
{[["expense", "filterExpenses"], ["income", "filterIncome"],
["uebertrag", "filterTransfers"]].map(([art, schluessel]) => (
<label className="checkline" key={art}>
<input
type="checkbox"
checked={filter.arten[art]}
onChange={(e) => setFilter({
...filter,
arten: { ...filter.arten, [art]: e.target.checked },
})}
/>
<span>{t(`transactions.${schluessel}`)}</span>
</label>
))}
</div>
</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>
<BudgetZeitraum
wert={form.budget_period}
datum={form.date}
startTag={Number(startTag) || 1}
onChange={(label) => setForm({ ...form, budget_period: label })}
/>
{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>
{/* Die Kategorie sagt, in welche Schublade es gehoert. Die Notiz
sagt, WAS es war – „Amazon" kann Einrichtung sein, Werkzeug
oder ein Geschenk, und in drei Monaten weiss das niemand mehr.
Freier Text, weil sich das nicht in Schubladen fassen laesst. */}
<label>
<span>{t("transactions.notes")}</span>
<textarea rows={1} value={form.notes}
placeholder={t("transactions.notesPlaceholder")}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</label>
{/* Tags quer zur Kategorie: „Urlaub Island", „absetzbar". Mehrere
duerfen es sein, deshalb keine einfache Auswahl. */}
<label>
<span>{t("tags.column")}</span>
<TagSelect
tags={tags}
value={form.tag_ids}
onChange={(ids) => setForm({ ...form, tag_ids: ids })}
placeholder={t("tags.none")}
/>
</label>
</form>
</Modal>
)}
</div>
);
}