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:
co-authored by
Claude Opus 5
parent
a9ea0432d8
commit
50fc0e3353
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user