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
@@ -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. */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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