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:
co-authored by
Claude Opus 5
parent
e503baa8a6
commit
cdf21474cf
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
const w = input.trim();
|
||||
if (!w) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = { word: w };
|
||||
const c = parseAmountToCents(amount);
|
||||
if (c) body.amount_cents = c;
|
||||
await api.addKeyword(category.id, body);
|
||||
function zeileLeeren() {
|
||||
setEditId(null);
|
||||
setInput("");
|
||||
setAmount("");
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
const w = input.trim();
|
||||
if (!w) return;
|
||||
const c = parseAmountToCents(amount);
|
||||
setBusy(true);
|
||||
try {
|
||||
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);
|
||||
}
|
||||
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();
|
||||
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 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,36 +114,35 @@ 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>
|
||||
) : (
|
||||
{words.map((w) => (
|
||||
<span
|
||||
className="kw-chip"
|
||||
className={editId === w.id ? "kw-chip aktiv" : "kw-chip"}
|
||||
key={w.id}
|
||||
title={t("categories.keywordEditHint")}
|
||||
onDoubleClick={() => starteBearbeiten(w)}
|
||||
@@ -140,8 +153,7 @@ function KeywordModal({ category, onClose }) {
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-2)", marginBottom: 0 }}>
|
||||
{t("categories.keywordEditHint")}
|
||||
|
||||
Reference in New Issue
Block a user