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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-20 13:49:43 +02:00
co-authored by Claude Opus 5
parent e503baa8a6
commit cdf21474cf
6 changed files with 127 additions and 81 deletions
+4 -3
View File
@@ -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()
+35
View File
@@ -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)
+7 -13
View File
@@ -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 {
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+75 -63
View File
@@ -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={<button className="btn primary" onClick={onClose}>{t("common.close")}</button>}
>
<p className="muted small" style={{ marginTop: 0 }}>{t("categories.keywordsHint")}</p>
<div className="kw-add">
<div className={editId ? "kw-add bearbeitet" : "kw-add"}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus
ref={wortFeld}
value={input}
placeholder={t("categories.keywordAdd")}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
onKeyDown={beiTaste}
/>
<input
value={amount}
@@ -100,48 +114,46 @@ function KeywordModal({ category, onClose }) {
placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
onKeyDown={beiTaste}
/>
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} />
<button
className={editId ? "btn primary" : "btn"}
onClick={speichern}
disabled={busy || !input.trim()}
title={editId ? t("common.save") : t("categories.keywordAddBtn")}
>
<Icon name={editId ? "check" : "plus"} size={15} />
</button>
{editId && (
<button className="btn ghost" onClick={zeileLeeren} title={t("common.cancel")}>
<Icon name="close" size={15} />
</button>
)}
</div>
{editId && (
<p className="muted small" style={{ marginTop: -8, marginBottom: "var(--sp-3)" }}>
{t("categories.keywordEditing")}
</p>
)}
{words.length === 0 ? (
<div className="muted small">{t("categories.keywordEmpty")}</div>
) : (
<>
<div className="kw-chips">
{words.map((w) =>
editId === w.id ? (
<span className="kw-chip editing" key={w.id}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus
value={editVal}
onFocus={(e) => 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)}
/>
</span>
) : (
<span
className="kw-chip"
key={w.id}
title={t("categories.keywordEditHint")}
onDoubleClick={() => starteBearbeiten(w)}
>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
</span>
)
)}
{words.map((w) => (
<span
className={editId === w.id ? "kw-chip aktiv" : "kw-chip"}
key={w.id}
title={t("categories.keywordEditHint")}
onDoubleClick={() => starteBearbeiten(w)}
>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
</span>
))}
</div>
<p className="muted small" style={{ marginTop: "var(--sp-2)", marginBottom: 0 }}>
{t("categories.keywordEditHint")}