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
+3
View File
@@ -10,6 +10,7 @@ import ChangePassword from "./pages/ChangePassword";
import Dashboard from "./pages/Dashboard";
import Accounts from "./pages/Accounts";
import Categories from "./pages/Categories";
import Tags from "./pages/Tags";
import Transactions from "./pages/Transactions";
import Budget from "./pages/Budget";
import Abos from "./pages/Abos";
@@ -51,6 +52,7 @@ function Sidebar() {
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
<NavItem to="/tags" icon="tags" label={t("nav.tags")} />
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/eingang" icon="inbox" label={t("nav.todo")} />
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
@@ -142,6 +144,7 @@ export default function App() {
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
<Route path="/tags" element={<Protected><Tags /></Protected>} />
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
{/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen,
stand jede Bank-Buchung ohne Kategorie in beiden Listen. */}
+7
View File
@@ -338,6 +338,13 @@ export const api = {
updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }),
deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }),
// Tags. Gesetzt werden sie an der Buchung (updateTransaction mit tag_ids),
// hier geht es nur um die Tags selbst.
listTags: () => request("/tags"),
createTag: (body) => request("/tags", { method: "POST", body }),
updateTag: (id, body) => request(`/tags/${id}`, { method: "PATCH", body }),
deleteTag: (id) => request(`/tags/${id}`, { method: "DELETE" }),
// Wechselkurse
rateStatus: () => request("/rates/status"),
convertRate: (amountCents, from, to, on) => {
+19
View File
@@ -0,0 +1,19 @@
// Farbiges Text-Plättchen für einen Tag.
//
// Nicht `.badge`: das kennt nur feste Varianten (accent/danger/warn/ok), ein
// Tag trägt aber eine frei gewählte Farbe. Der getönte Hintergrund stammt aus
// demselben Kniff wie beim IconChip – `${color}22` ist dieselbe Farbe mit 13 %
// Deckkraft. Dadurch bleibt der Text in Hell UND Dunkel lesbar, ohne dass für
// jede Farbe ein zweiter Wert gepflegt werden müsste.
export default function TagChip({ name, color = "#6b7480", onRemove, removeTitle }) {
return (
<span className="tag-chip" style={{ color, background: `${color}22` }}>
{name}
{onRemove && (
<button type="button" title={removeTitle} onClick={onRemove} aria-label={removeTitle}>
×
</button>
)}
</span>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import TagChip from "./TagChip";
// Mehrfachauswahl für Tags. Bewusst nach dem Vorbild von CategorySelect
// gebaut und mit denselben `.catsel-*`-Klassen: Das Panel wird per Portal mit
// fester Position gerendert, weil es sonst in der scrollbaren Buchungstabelle
// abgeschnitten würde. Unterschied zum Vorbild: flache Liste statt Baum, und
// ein Klick schaltet um statt zu schliessen – man vergibt selten genau einen.
export default function TagSelect({
tags = [],
value = [],
onChange,
minWidth = 0,
disabled = false,
placeholder = null,
}) {
const { t } = useTranslation();
const gewaehlt = useMemo(() => new Set((value || []).map(Number)), [value]);
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const panelRef = useRef(null);
const searchRef = useRef(null);
const byId = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
const sel = useMemo(
() => [...gewaehlt].map((id) => byId[id]).filter(Boolean),
[gewaehlt, byId],
);
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const width = Math.max(r.width, 240);
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const desired = 320;
let top;
let maxHeight;
if (spaceBelow >= 220 || spaceBelow >= spaceAbove) {
maxHeight = Math.min(desired, spaceBelow - 10);
top = r.bottom + 4;
} else {
maxHeight = Math.min(desired, spaceAbove - 10);
top = r.top - 4 - maxHeight;
}
let left = r.left;
if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width;
if (left < 8) left = 8;
setPos({ left, top, width, maxHeight });
}
function openPanel() {
if (disabled) return;
computePos();
setQ("");
setOpen(true);
}
useEffect(() => {
if (!open) return undefined;
searchRef.current?.focus();
const onDown = (e) => {
if (panelRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
setOpen(false);
};
const onKey = (e) => e.key === "Escape" && setOpen(false);
// Wie beim CategorySelect: Scrollen INNERHALB der Liste darf sie nicht
// schliessen, Scrollen der Seite darunter schon.
const onScrollResize = (e) => {
if (e?.target && panelRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScrollResize, true);
window.addEventListener("resize", onScrollResize);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScrollResize, true);
window.removeEventListener("resize", onScrollResize);
};
}, [open]);
function umschalten(id) {
const next = new Set(gewaehlt);
if (next.has(id)) next.delete(id);
else next.add(id);
// Offen lassen: Wer einen Tag setzt, setzt oft gleich den zweiten.
onChange([...next]);
}
const ql = q.trim().toLowerCase();
const sichtbar = ql ? tags.filter((x) => x.name.toLowerCase().includes(ql)) : tags;
return (
<div className="catsel" style={{ minWidth }}>
<button
type="button"
ref={btnRef}
className={`catsel-btn${open ? " open" : ""}`}
onClick={() => (open ? setOpen(false) : openPanel())}
disabled={disabled}
>
{sel.length ? (
<span className="catsel-value tag-chips">
{sel.map((x) => <TagChip key={x.id} name={x.name} color={x.color} />)}
</span>
) : (
<span className="catsel-value muted ellipsis">
{placeholder || t("tags.choose")}
</span>
)}
<Icon name="chevron-down" size={15} className="catsel-caret" />
</button>
{open && pos && createPortal(
<div
ref={panelRef}
className="catsel-panel"
style={{ left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight }}
>
<div className="catsel-search">
<Icon name="search" size={14} />
<input
ref={searchRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("tags.searchPlaceholder")}
/>
</div>
<div className="catsel-list">
{sichtbar.map((x) => (
<button
type="button"
key={x.id}
className={`catsel-opt${gewaehlt.has(x.id) ? " selected" : ""}`}
onClick={() => umschalten(x.id)}
>
<TagChip name={x.name} color={x.color} />
<span className="grow" />
{gewaehlt.has(x.id) && <Icon name="check" size={15} className="catsel-check" />}
</button>
))}
{sichtbar.length === 0 && (
<div className="catsel-empty muted small">
{tags.length === 0 ? t("tags.empty") : t("tags.noMatch")}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
+43
View File
@@ -2036,3 +2036,46 @@ tr.row-dup {
.sso-zeile:last-child { border-bottom: none; }
.sso-haken { color: var(--ok); flex: none; }
.btn.sso-verknuepfen { justify-content: center; text-decoration: none; }
/* ---------- Tags ---------- */
/* Farbiges Text-Plaettchen. Die Farbe kommt aus dem Datensatz und steht
deshalb inline am Element (siehe TagChip); hier nur die Form. */
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 9px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 560;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-chip button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: inherit;
font-size: 0.95rem;
line-height: 1;
opacity: 0.65;
cursor: pointer;
}
.tag-chip button:hover { opacity: 1; }
/* Mehrere Plaettchen im Auswahlknopf: Sie duerfen umbrechen, sonst wuerde in
der Tabellenzelle ab dem zweiten Tag nichts mehr zu sehen sein. */
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
+18 -1
View File
@@ -24,7 +24,8 @@
"users": "Benutzer",
"settings": "Einstellungen",
"todo": "Zu erledigen",
"abos": "Abos"
"abos": "Abos",
"tags": "Tags"
},
"login": {
"subtitle": "Bitte anmelden",
@@ -242,6 +243,22 @@
"autoTarget": "automatisch",
"empty": "Noch keine Transfer-Schlagwörter."
},
"tags": {
"title": "Tags",
"intro": "Tags liegen quer zur Kategorie: Eine Buchung hat genau eine Kategorie, aber beliebig viele Tags – etwa „Urlaub Island“, „absetzbar“ oder „zurückfordern“. Gesetzt werden sie an der Buchung, in der Liste oder beim Bearbeiten.",
"new": "Neuer Tag",
"edit": "Tag bearbeiten",
"name": "Name",
"color": "Farbe",
"preview": "Vorschau",
"column": "Tags",
"none": "Keine Tags",
"choose": "Tags wählen …",
"searchPlaceholder": "Tag suchen …",
"noMatch": "Kein Tag passt dazu.",
"empty": "Noch keine Tags angelegt.",
"deleteConfirm": "Den Tag „{{name}}“ löschen? Die Buchungen bleiben stehen – sie verlieren nur dieses Etikett."
},
"categories": {
"title": "Kategorien",
"newGroup": "Neue Gruppe",
+18 -1
View File
@@ -24,7 +24,8 @@
"users": "Users",
"settings": "Settings",
"todo": "To do",
"abos": "Subscriptions"
"abos": "Subscriptions",
"tags": "Tags"
},
"login": {
"subtitle": "Please sign in",
@@ -242,6 +243,22 @@
"autoTarget": "automatic",
"empty": "No transfer keywords yet."
},
"tags": {
"title": "Tags",
"intro": "Tags sit across categories: a transaction has exactly one category but any number of tags – say “Iceland trip”, “deductible” or “claim back”. You set them on the transaction, in the list or while editing.",
"new": "New tag",
"edit": "Edit tag",
"name": "Name",
"color": "Colour",
"preview": "Preview",
"column": "Tags",
"none": "No tags",
"choose": "Choose tags …",
"searchPlaceholder": "Search tag …",
"noMatch": "No tag matches.",
"empty": "No tags yet.",
"deleteConfirm": "Delete the tag “{{name}}”? The transactions stay – they just lose this label."
},
"categories": {
"title": "Categories",
"newGroup": "New group",
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import Icon from "../components/Icon";
import Modal from "../components/Modal";
import TagChip from "../components/TagChip";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
// Tags pflegen: anlegen, umbenennen, umfärben, löschen.
//
// Bewusst eine eigene Seite und nicht ein Abschnitt unter den Kategorien: Ein
// Tag ist etwas anderes als eine Kategorie. Die Kategorie beantwortet „wofür
// war das Geld" und gilt genau einmal; ein Tag legt sich quer darüber und darf
// mehrfach an derselben Buchung hängen.
//
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
const STANDARDFARBE = "#3f51b5";
export default function Tags() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const [tags, setTags] = useState([]);
const [modal, setModal] = useState(null); // { editing, form }
async function laden() {
try {
setTags(await api.listTags());
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { laden(); }, []);
function oeffnen(editing = null) {
setModal({
editing,
form: editing
? { name: editing.name, color: editing.color }
: { name: "", color: STANDARDFARBE },
});
}
async function speichern(e) {
e.preventDefault();
const { editing, form } = modal;
const rumpf = { name: form.name.trim(), color: form.color };
try {
if (editing) await api.updateTag(editing.id, rumpf);
else await api.createTag(rumpf);
setModal(null);
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function loeschen(tag) {
if (!(await confirm({
title: t("common.delete"),
message: t("tags.deleteConfirm", { name: tag.name }),
danger: true,
confirmLabel: t("common.delete"),
}))) return;
try {
await api.deleteTag(tag.id);
laden();
} catch (err) {
toast(err.message, "error");
}
}
return (
<div>
<div className="page-head">
<h1>{t("tags.title")}</h1>
<button className="btn primary" onClick={() => oeffnen()}>
<Icon name="plus" size={16} /> {t("tags.new")}
</button>
</div>
<div className="card">
<p className="muted small" style={{ marginTop: 0 }}>{t("tags.intro")}</p>
{tags.map((tag) => (
<div className="cat-row" key={tag.id}>
<TagChip name={tag.name} color={tag.color} />
<span className="grow" />
<button className="btn sm ghost" title={t("common.edit")} onClick={() => oeffnen(tag)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tag)}>
<Icon name="trash" size={14} />
</button>
</div>
))}
{tags.length === 0 && <div className="empty-state">{t("tags.empty")}</div>}
</div>
{modal && (
<Modal
title={modal.editing ? t("tags.edit") : t("tags.new")}
onClose={() => setModal(null)}
footer={
<>
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="tag-form" type="submit">{t("common.save")}</button>
</>
}
>
<form id="tag-form" onSubmit={speichern}>
<label>
<span>{t("tags.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus
maxLength={60}
value={modal.form.name}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })}
required
/>
</label>
<label>
<span>{t("tags.color")}</span>
<div className="color-row">
<input
type="color"
value={modal.form.color}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })}
/>
<TagChip name={modal.form.name.trim() || t("tags.preview")} color={modal.form.color} />
</div>
</label>
</form>
</Modal>
)}
</div>
);
}
+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>
)}