feat: Buchungen lassen sich mit eigenen, farbigen Tags versehen und danach filtern

Die Kategorie beantwortet "wofuer war das Geld" und gilt genau einmal. Alles,
was quer dazu liegt – "Urlaub Island", "absetzbar", "zurueckfordern" –, hatte
bisher keinen Platz, weil eine Buchung davon mehrere auf einmal sein kann.

Tags werden auf einer eigenen Seite angelegt, jeder mit seiner Farbe. Gesetzt
werden sie an der Buchung: direkt in der Liste oder beim Bearbeiten. Die
Spalte laesst sich filtern, auch nach mehreren Tags gleichzeitig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-19 07:16:17 +02:00
co-authored by Claude Opus 5
parent a9ea0432d8
commit 50fc0e3353
16 changed files with 961 additions and 11 deletions
+65 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
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";
@@ -33,6 +34,7 @@ function emptyForm() {
payee: "",
description: "",
notes: "",
tag_ids: [],
budget_as_expense: true,
budget_category_id: "",
// Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es.
@@ -56,12 +58,17 @@ export default function Transactions() {
const [uebertrag, setUebertrag] = useState(null);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [tags, setTags] = 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 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;
@@ -70,9 +77,12 @@ export default function Transactions() {
async function ladenStamm() {
try {
const [accs, cats] = await Promise.all([api.listAccounts(), api.listCategories()]);
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");
}
@@ -120,6 +130,31 @@ export default function Transactions() {
}
}
// 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;
@@ -243,6 +278,7 @@ export default function Transactions() {
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:
@@ -271,6 +307,7 @@ export default function Transactions() {
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") {
@@ -505,6 +542,20 @@ export default function Transactions() {
)
),
},
{
// 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 || "",
@@ -582,7 +633,7 @@ export default function Transactions() {
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
formatDate, links, paarZu, vorschlag, abos, t]);
formatDate, links, paarZu, vorschlag, abos, tags, tagById, t]);
return (
<div>
@@ -755,6 +806,17 @@ export default function Transactions() {
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>
)}