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>
863 lines
36 KiB
React
863 lines
36 KiB
React
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>
|
||
);
|
||
}
|