From cdf21474cfdfc037b5e97ce8687486ca842cf786 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 20 Aug 2026 13:49:43 +0200 Subject: [PATCH] feat: Schlagwort per Doppelklick oben in der Zeile bearbeiten Bisher oeffnete der Doppelklick ein Eingabefeld im Chip selbst. Dort war nur Platz fuer das Wort - der Betrag liess sich gar nicht aendern, weil die Inline-Bearbeitung bewusst nur { word } geschickt hat. Jetzt laedt der Doppelklick Wort und Betrag in dieselbe Zeile, in der auch neue Schlagwoerter entstehen. Der Knopf wechselt auf ein Haekchen, daneben steht Abbrechen (auch per Escape), und der bearbeitete Chip ist markiert. Gespeichert wird per PATCH mit ausdruecklichem amount_cents, damit ein geleertes Betragsfeld den Betrag auch wirklich loescht; ein geparstes 0 wird zu null, weil das Schema gt=0 verlangt. Ueber POST ginge es nicht: das antwortet bei vorhandenem Wort mit geaendertem Betrag mit 409. Tests decken jetzt ab, dass Betrag mitgeaendert wird, beim Weglassen stehen bleibt und bei ausdruecklichem null verschwindet. Co-Authored-By: Claude Opus 5 --- backend/app/routers/categories.py | 7 +- backend/tests/test_keywords.py | 35 ++++++++ web/src/finance.css | 20 ++--- web/src/locales/de.json | 4 +- web/src/locales/en.json | 4 +- web/src/pages/Categories.jsx | 138 ++++++++++++++++-------------- 6 files changed, 127 insertions(+), 81 deletions(-) diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index f34c301..11ba626 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -245,8 +245,8 @@ def update_keyword( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): - """Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das - Muster ändert, werden die Korrektur-Zähler zurückgesetzt.""" + """Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster, + werden die Korrektur-Zähler zurückgesetzt.""" rule = _owned_manual_rule(db, rule_id, user) pattern = normalize_text(payload.word)[:255] if not pattern: @@ -259,7 +259,8 @@ def update_keyword( rule.applied_count = 0 rule.overridden_count = 0 rule.confirmed = False - # Betrag nur ändern, wenn mitgeschickt (leere Inline-Wortänderung darf ihn nicht löschen). + # Betrag nur anfassen, wenn er mitgeschickt wurde. Ausdrücklich null löscht + # ihn (das leere Betragsfeld im Popup), ein Weglassen lässt ihn stehen. if "amount_cents" in payload.model_fields_set: rule.amount_cents = payload.amount_cents db.commit() diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index 734fa3a..f46d76e 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -135,6 +135,41 @@ def test_keyword_update_and_conflict(admin): assert admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": "coop"}).status_code == 409 +def test_keyword_update_amount(admin): + """Wort und Betrag gemeinsam ändern – das Popup lädt beides in die Zeile.""" + (a,) = _expense_cats(admin, 1) + kw = admin.post( + f"/api/categories/{a['id']}/keywords", json={"word": "sbb", "amount_cents": 500} + ).json() + assert kw["amount_cents"] == 500 + + r = admin.patch( + f"/api/categories/keywords/{kw['id']}", json={"word": "sbb", "amount_cents": 750} + ) + assert r.status_code == 200, r.text + assert r.json()["amount_cents"] == 750 + + # Nur das Wort schicken lässt den Betrag stehen. + r = admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": "sbb cff"}) + assert r.status_code == 200, r.text + assert r.json()["amount_cents"] == 750 + + # Ausdrücklich null löscht ihn (geleertes Betragsfeld im Popup). + r = admin.patch( + f"/api/categories/keywords/{kw['id']}", json={"word": "sbb cff", "amount_cents": None} + ) + assert r.status_code == 200, r.text + assert r.json()["amount_cents"] is None + gespeichert = admin.get(f"/api/categories/{a['id']}/keywords").json() + assert next(k for k in gespeichert if k["id"] == kw["id"])["amount_cents"] is None + + # Null ist erlaubt, null-als-Betrag nicht. + r = admin.patch( + f"/api/categories/keywords/{kw['id']}", json={"word": "sbb cff", "amount_cents": 0} + ) + assert r.status_code == 422 + + def test_below_threshold_not_flagged(admin): giro = _account(admin, name="Giro NoFlag") a, b = _expense_cats(admin, 2) diff --git a/web/src/finance.css b/web/src/finance.css index 75f1e3d..00602a2 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -777,22 +777,16 @@ tr.row-dup { background: var(--danger); color: #fff; } -.kw-chip.editing { - padding: 2px 6px; +/* Das Schlagwort, das gerade oben in der Zeile liegt. Bearbeitet wird dort, + nicht im Chip – im Chip war für den Betrag kein Platz. */ +.kw-chip.aktiv { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } -.kw-chip.editing input { - width: 12ch; - min-width: 70px; - margin: 0; - padding: 2px 2px; - border: none; - background: none; - outline: none; - font: inherit; - font-size: 0.85rem; - color: var(--text); +/* Passend dazu die Eingabezeile hervorheben, damit klar ist, dass dort gerade + ein vorhandenes Wort liegt und nicht ein neues entsteht. */ +.kw-add.bearbeitet input { + border-color: var(--accent); } .kw-flag { diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 60e4f46..a1241bf 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -186,7 +186,9 @@ "keywordAdd": "Wort eingeben und Enter", "keywordAmount": "Betrag (optional)", "keywordEmpty": "Noch keine Schlagwörter.", - "keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.", + "keywordAddBtn": "Hinzufügen", + "keywordEditHint": "Doppelklick auf ein Schlagwort lädt es mit Betrag oben zum Bearbeiten.", + "keywordEditing": "Vorhandenes Schlagwort wird bearbeitet – Escape bricht ab.", "searchPlaceholder": "Kategorie suchen …" }, "transactions": { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 52b264a..c565024 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -186,7 +186,9 @@ "keywordAdd": "Type a word and press Enter", "keywordAmount": "Amount (optional)", "keywordEmpty": "No keywords yet.", - "keywordEditHint": "Double-click a keyword to edit it.", + "keywordAddBtn": "Add", + "keywordEditHint": "Double-click a keyword to load it, with its amount, into the row above.", + "keywordEditing": "Editing an existing keyword – press Escape to cancel.", "searchPlaceholder": "Search category …" }, "transactions": { diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index f974907..0ee2556 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useConfirm } from "../confirm"; @@ -12,6 +12,11 @@ import { centsToInput, parseAmountToCents } from "../format"; // Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import // gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu. // Optional lässt sich ein fester Betrag als Zusatzbedingung angeben. +// +// Bearbeitet wird in derselben Zeile, in der auch neue Wörter entstehen: ein +// Doppelklick auf ein Schlagwort lädt Wort und Betrag dorthin. Vorher wurde im +// Chip selbst getippt – dort war für den Betrag kein Platz, er liess sich also +// gar nicht ändern. function KeywordModal({ category, onClose }) { const { t } = useTranslation(); const toast = useToast(); @@ -19,8 +24,9 @@ function KeywordModal({ category, onClose }) { const [input, setInput] = useState(""); const [amount, setAmount] = useState(""); const [busy, setBusy] = useState(false); + // Gesetzt, solange ein vorhandenes Schlagwort in der Zeile liegt. const [editId, setEditId] = useState(null); - const [editVal, setEditVal] = useState(""); + const wortFeld = useRef(null); async function laden() { try { setWords(await api.listKeywords(category.id)); } @@ -28,17 +34,29 @@ function KeywordModal({ category, onClose }) { } useEffect(() => { laden(); }, [category.id]); - async function hinzufuegen() { + function zeileLeeren() { + setEditId(null); + setInput(""); + setAmount(""); + } + + async function speichern() { const w = input.trim(); if (!w) return; + const c = parseAmountToCents(amount); setBusy(true); try { - const body = { word: w }; - const c = parseAmountToCents(amount); - if (c) body.amount_cents = c; - await api.addKeyword(category.id, body); - setInput(""); - setAmount(""); + if (editId) { + // Betrag ausdrücklich mitschicken – auch als null. Der Server lässt ihn + // sonst stehen, und ein geleertes Feld hätte keine Wirkung. + await api.updateKeyword(editId, { word: w, amount_cents: c || null }); + } else { + const body = { word: w }; + if (c) body.amount_cents = c; + await api.addKeyword(category.id, body); + } + zeileLeeren(); + // Der Server normalisiert das Wort, deshalb neu laden statt lokal zu raten. await laden(); } catch (err) { toast(err.message, "error"); @@ -46,7 +64,9 @@ function KeywordModal({ category, onClose }) { setBusy(false); } } + async function entfernen(id) { + if (id === editId) zeileLeeren(); try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); @@ -57,25 +77,18 @@ function KeywordModal({ category, onClose }) { function starteBearbeiten(w) { setEditId(w.id); - setEditVal(w.word); + setInput(w.word); + setAmount(centsToInput(w.amount_cents)); + // Wort markiert vorlegen: überschreiben ist der häufigere Fall. + requestAnimationFrame(() => { + wortFeld.current?.focus(); + wortFeld.current?.select(); + }); } - function abbrechenBearbeiten() { - setEditId(null); - setEditVal(""); - } - async function speichernBearbeiten(id) { - const val = editVal.trim(); - const orig = words.find((x) => x.id === id); - if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; } - try { - await api.updateKeyword(id, { word: val }); - abbrechenBearbeiten(); - await laden(); - } catch (err) { - toast(err.message, "error"); - abbrechenBearbeiten(); - await laden(); - } + + function beiTaste(e) { + if (e.key === "Enter") { e.preventDefault(); speichern(); } + else if (e.key === "Escape" && editId) { e.preventDefault(); zeileLeeren(); } } return ( @@ -85,14 +98,15 @@ function KeywordModal({ category, onClose }) { footer={} >

{t("categories.keywordsHint")}

-
+
{/* eslint-disable-next-line jsx-a11y/no-autofocus */} setInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} + onKeyDown={beiTaste} /> setAmount(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} + onKeyDown={beiTaste} /> - + {editId && ( + + )}
+ {editId && ( +

+ {t("categories.keywordEditing")} +

+ )} {words.length === 0 ? (
{t("categories.keywordEmpty")}
) : ( <>
- {words.map((w) => - editId === w.id ? ( - - {/* eslint-disable-next-line jsx-a11y/no-autofocus */} - e.target.select()} - onChange={(e) => setEditVal(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); speichernBearbeiten(w.id); } - else if (e.key === "Escape") { e.preventDefault(); abbrechenBearbeiten(); } - }} - onBlur={() => speichernBearbeiten(w.id)} - /> - - ) : ( - starteBearbeiten(w)} - > - {w.word} - {w.amount_cents ? = {centsToInput(w.amount_cents)} : null} - - - ) - )} + {words.map((w) => ( + starteBearbeiten(w)} + > + {w.word} + {w.amount_cents ? = {centsToInput(w.amount_cents)} : null} + + + ))}

{t("categories.keywordEditHint")}