feat: Schlagwörter je Kategorie + auf-/zuklappbares Kategorie-Dropdown

- Manuelle Schlagwörter je Kategorie (Popup mit Chips, Doppelklick zum Bearbeiten)
- Treffer über Empfänger ODER Verwendungszweck; längstes Schlagwort gewinnt;
  Kind-Filter (Ausgabe/Einnahme); wirken nur bei künftigen Importen
- Lernende Korrektur-Erkennung: ab 5 Anwendungen und >= 1/3 Korrekturen
  erscheint eine Prüf-Meldung in den Einstellungen (Behalten/Entfernen)
- Neue CategorySelect-Komponente (Baum + Suche, per Portal) ersetzt die
  nativen optgroup-Selects in Buchungen und Import-Prüfansicht
- Migration 0006 (source/applied_count/overridden_count/confirmed, match_field any)
- Endpunkte, i18n DE/EN, Tests (test_keywords: 7 neu, gesamt 27 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 21:31:29 +02:00
co-authored by Claude Opus 4.8
parent f375fc5eaa
commit 37f64999cd
17 changed files with 1137 additions and 45 deletions
+41
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "../settings";
import { useAuth } from "../auth";
@@ -10,6 +11,22 @@ export default function Settings() {
const s = useSettings();
const { isAdmin } = useAuth();
const toast = useToast();
const [flagged, setFlagged] = useState([]);
async function ladenFlagged() {
try { setFlagged(await api.flaggedKeywords()); }
catch { /* still ignorieren – nicht kritisch für die Einstellungen */ }
}
useEffect(() => { ladenFlagged(); }, []);
async function keywordBehalten(id) {
try { await api.confirmKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
}
async function keywordEntfernen(id) {
try { await api.deleteKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
}
async function set(key, value) {
try {
@@ -32,6 +49,30 @@ export default function Settings() {
<div>
<h1>{t("settings.title")}</h1>
{flagged.length > 0 && (
<div className="card" style={{ maxWidth: 520, marginBottom: "var(--sp-4)", borderColor: "var(--danger)" }}>
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="alert" size={18} /> {t("settings.keywordReview")}
</h2>
<p className="muted small">{t("settings.keywordReviewHint")}</p>
{flagged.map((f) => (
<div className="kw-flag" key={f.id}>
<div className="kw-flag-main">
<div className="ellipsis">
<span className="kw-flag-word">{f.word}</span>
<Icon name="chevron-right" size={12} /> {f.category_name}
</div>
<div className="muted small">
{t("settings.keywordCorrected", { overridden: f.overridden_count, applied: f.applied_count })}
</div>
</div>
<button className="btn sm" onClick={() => keywordBehalten(f.id)}>{t("settings.keep")}</button>
<button className="btn sm danger" onClick={() => keywordEntfernen(f.id)}>{t("settings.remove")}</button>
</div>
))}
</div>
)}
<div className="card" style={{ maxWidth: 520 }}>
<label>
<span>{t("settings.baseCurrency")}</span>