+
+
+ {open && pos && createPortal(
+
+
+
+ setQ(e.target.value)}
+ placeholder={t("tags.searchPlaceholder")}
+ />
+
+
+ {sichtbar.map((x) => (
+
+ ))}
+ {sichtbar.length === 0 && (
+
+ {tags.length === 0 ? t("tags.empty") : t("tags.noMatch")}
+
+ )}
+
+
,
+ document.body,
+ )}
+
+ );
+}
diff --git a/web/src/finance.css b/web/src/finance.css
index 7ac02d7..c23bfa3 100644
--- a/web/src/finance.css
+++ b/web/src/finance.css
@@ -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;
+}
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index 804904d..29b3cd4 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -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",
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 0404d12..8a89d1d 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -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",
diff --git a/web/src/pages/Tags.jsx b/web/src/pages/Tags.jsx
new file mode 100644
index 0000000..362ec9a
--- /dev/null
+++ b/web/src/pages/Tags.jsx
@@ -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 (
+ 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 (
@@ -755,6 +806,17 @@ export default function Transactions() {
placeholder={t("transactions.notesPlaceholder")}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
+ {/* Tags quer zur Kategorie: „Urlaub Island", „absetzbar". Mehrere
+ duerfen es sein, deshalb keine einfache Auswahl. */}
+
)}