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