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),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das
|
"""Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster,
|
||||||
Muster ändert, werden die Korrektur-Zähler zurückgesetzt."""
|
werden die Korrektur-Zähler zurückgesetzt."""
|
||||||
rule = _owned_manual_rule(db, rule_id, user)
|
rule = _owned_manual_rule(db, rule_id, user)
|
||||||
pattern = normalize_text(payload.word)[:255]
|
pattern = normalize_text(payload.word)[:255]
|
||||||
if not pattern:
|
if not pattern:
|
||||||
@@ -259,7 +259,8 @@ def update_keyword(
|
|||||||
rule.applied_count = 0
|
rule.applied_count = 0
|
||||||
rule.overridden_count = 0
|
rule.overridden_count = 0
|
||||||
rule.confirmed = False
|
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:
|
if "amount_cents" in payload.model_fields_set:
|
||||||
rule.amount_cents = payload.amount_cents
|
rule.amount_cents = payload.amount_cents
|
||||||
db.commit()
|
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
|
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):
|
def test_below_threshold_not_flagged(admin):
|
||||||
giro = _account(admin, name="Giro NoFlag")
|
giro = _account(admin, name="Giro NoFlag")
|
||||||
a, b = _expense_cats(admin, 2)
|
a, b = _expense_cats(admin, 2)
|
||||||
|
|||||||
+7
-13
@@ -777,22 +777,16 @@ tr.row-dup {
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.kw-chip.editing {
|
/* Das Schlagwort, das gerade oben in der Zeile liegt. Bearbeitet wird dort,
|
||||||
padding: 2px 6px;
|
nicht im Chip – im Chip war für den Betrag kein Platz. */
|
||||||
|
.kw-chip.aktiv {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||||
}
|
}
|
||||||
.kw-chip.editing input {
|
/* Passend dazu die Eingabezeile hervorheben, damit klar ist, dass dort gerade
|
||||||
width: 12ch;
|
ein vorhandenes Wort liegt und nicht ein neues entsteht. */
|
||||||
min-width: 70px;
|
.kw-add.bearbeitet input {
|
||||||
margin: 0;
|
border-color: var(--accent);
|
||||||
padding: 2px 2px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
outline: none;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kw-flag {
|
.kw-flag {
|
||||||
|
|||||||
@@ -186,7 +186,9 @@
|
|||||||
"keywordAdd": "Wort eingeben und Enter",
|
"keywordAdd": "Wort eingeben und Enter",
|
||||||
"keywordAmount": "Betrag (optional)",
|
"keywordAmount": "Betrag (optional)",
|
||||||
"keywordEmpty": "Noch keine Schlagwörter.",
|
"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 …"
|
"searchPlaceholder": "Kategorie suchen …"
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
|
|||||||
@@ -186,7 +186,9 @@
|
|||||||
"keywordAdd": "Type a word and press Enter",
|
"keywordAdd": "Type a word and press Enter",
|
||||||
"keywordAmount": "Amount (optional)",
|
"keywordAmount": "Amount (optional)",
|
||||||
"keywordEmpty": "No keywords yet.",
|
"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 …"
|
"searchPlaceholder": "Search category …"
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useConfirm } from "../confirm";
|
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
|
// Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import
|
||||||
// gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu.
|
// gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu.
|
||||||
// Optional lässt sich ein fester Betrag als Zusatzbedingung angeben.
|
// 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 }) {
|
function KeywordModal({ category, onClose }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -19,8 +24,9 @@ function KeywordModal({ category, onClose }) {
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
// Gesetzt, solange ein vorhandenes Schlagwort in der Zeile liegt.
|
||||||
const [editId, setEditId] = useState(null);
|
const [editId, setEditId] = useState(null);
|
||||||
const [editVal, setEditVal] = useState("");
|
const wortFeld = useRef(null);
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
try { setWords(await api.listKeywords(category.id)); }
|
try { setWords(await api.listKeywords(category.id)); }
|
||||||
@@ -28,17 +34,29 @@ function KeywordModal({ category, onClose }) {
|
|||||||
}
|
}
|
||||||
useEffect(() => { laden(); }, [category.id]);
|
useEffect(() => { laden(); }, [category.id]);
|
||||||
|
|
||||||
async function hinzufuegen() {
|
function zeileLeeren() {
|
||||||
|
setEditId(null);
|
||||||
|
setInput("");
|
||||||
|
setAmount("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern() {
|
||||||
const w = input.trim();
|
const w = input.trim();
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
|
const c = parseAmountToCents(amount);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const body = { word: w };
|
if (editId) {
|
||||||
const c = parseAmountToCents(amount);
|
// Betrag ausdrücklich mitschicken – auch als null. Der Server lässt ihn
|
||||||
if (c) body.amount_cents = c;
|
// sonst stehen, und ein geleertes Feld hätte keine Wirkung.
|
||||||
await api.addKeyword(category.id, body);
|
await api.updateKeyword(editId, { word: w, amount_cents: c || null });
|
||||||
setInput("");
|
} else {
|
||||||
setAmount("");
|
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();
|
await laden();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, "error");
|
toast(err.message, "error");
|
||||||
@@ -46,7 +64,9 @@ function KeywordModal({ category, onClose }) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function entfernen(id) {
|
async function entfernen(id) {
|
||||||
|
if (id === editId) zeileLeeren();
|
||||||
try {
|
try {
|
||||||
await api.deleteKeyword(id);
|
await api.deleteKeyword(id);
|
||||||
setWords((ws) => ws.filter((x) => x.id !== id));
|
setWords((ws) => ws.filter((x) => x.id !== id));
|
||||||
@@ -57,25 +77,18 @@ function KeywordModal({ category, onClose }) {
|
|||||||
|
|
||||||
function starteBearbeiten(w) {
|
function starteBearbeiten(w) {
|
||||||
setEditId(w.id);
|
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);
|
function beiTaste(e) {
|
||||||
setEditVal("");
|
if (e.key === "Enter") { e.preventDefault(); speichern(); }
|
||||||
}
|
else if (e.key === "Escape" && editId) { e.preventDefault(); zeileLeeren(); }
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,14 +98,15 @@ function KeywordModal({ category, onClose }) {
|
|||||||
footer={<button className="btn primary" onClick={onClose}>{t("common.close")}</button>}
|
footer={<button className="btn primary" onClick={onClose}>{t("common.close")}</button>}
|
||||||
>
|
>
|
||||||
<p className="muted small" style={{ marginTop: 0 }}>{t("categories.keywordsHint")}</p>
|
<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 */}
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
ref={wortFeld}
|
||||||
value={input}
|
value={input}
|
||||||
placeholder={t("categories.keywordAdd")}
|
placeholder={t("categories.keywordAdd")}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
|
onKeyDown={beiTaste}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={amount}
|
value={amount}
|
||||||
@@ -100,48 +114,46 @@ function KeywordModal({ category, onClose }) {
|
|||||||
placeholder={t("categories.keywordAmount")}
|
placeholder={t("categories.keywordAmount")}
|
||||||
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
|
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
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()}>
|
<button
|
||||||
<Icon name="plus" size={15} />
|
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>
|
</button>
|
||||||
|
{editId && (
|
||||||
|
<button className="btn ghost" onClick={zeileLeeren} title={t("common.cancel")}>
|
||||||
|
<Icon name="close" size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editId && (
|
||||||
|
<p className="muted small" style={{ marginTop: -8, marginBottom: "var(--sp-3)" }}>
|
||||||
|
{t("categories.keywordEditing")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{words.length === 0 ? (
|
{words.length === 0 ? (
|
||||||
<div className="muted small">{t("categories.keywordEmpty")}</div>
|
<div className="muted small">{t("categories.keywordEmpty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="kw-chips">
|
<div className="kw-chips">
|
||||||
{words.map((w) =>
|
{words.map((w) => (
|
||||||
editId === w.id ? (
|
<span
|
||||||
<span className="kw-chip editing" key={w.id}>
|
className={editId === w.id ? "kw-chip aktiv" : "kw-chip"}
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
key={w.id}
|
||||||
<input
|
title={t("categories.keywordEditHint")}
|
||||||
autoFocus
|
onDoubleClick={() => starteBearbeiten(w)}
|
||||||
value={editVal}
|
>
|
||||||
onFocus={(e) => e.target.select()}
|
{w.word}
|
||||||
onChange={(e) => setEditVal(e.target.value)}
|
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
|
||||||
onKeyDown={(e) => {
|
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
|
||||||
if (e.key === "Enter") { e.preventDefault(); speichernBearbeiten(w.id); }
|
<Icon name="close" size={13} />
|
||||||
else if (e.key === "Escape") { e.preventDefault(); abbrechenBearbeiten(); }
|
</button>
|
||||||
}}
|
</span>
|
||||||
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>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small" style={{ marginTop: "var(--sp-2)", marginBottom: 0 }}>
|
<p className="muted small" style={{ marginTop: "var(--sp-2)", marginBottom: 0 }}>
|
||||||
{t("categories.keywordEditHint")}
|
{t("categories.keywordEditHint")}
|
||||||
|
|||||||
Reference in New Issue
Block a user