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")}