import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; import Modal from "../components/Modal"; import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker"; 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(); const [words, setWords] = useState([]); 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 wortFeld = useRef(null); async function laden() { try { setWords(await api.listKeywords(category.id)); } catch (err) { toast(err.message, "error"); } } useEffect(() => { laden(); }, [category.id]); 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"); } finally { setBusy(false); } } async function entfernen(id) { if (id === editId) zeileLeeren(); try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); } catch (err) { toast(err.message, "error"); } } function starteBearbeiten(w) { setEditId(w.id); 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 ( {t("common.close")}} >

{t("categories.keywordsHint")}

{/* eslint-disable-next-line jsx-a11y/no-autofocus */} setInput(e.target.value)} onKeyDown={beiTaste} /> setAmount(e.target.value)} onKeyDown={beiTaste} /> {editId && ( )}
{editId && (

{t("categories.keywordEditing")}

)} {words.length === 0 ? (
{t("categories.keywordEmpty")}
) : ( <>
{words.map((w) => ( starteBearbeiten(w)} > {w.word} {w.amount_cents ? = {centsToInput(w.amount_cents)} : null} ))}

{t("categories.keywordEditHint")}

)}
); } export default function Categories() { const { t } = useTranslation(); const confirm = useConfirm(); const toast = useToast(); const [groups, setGroups] = useState([]); const [modal, setModal] = useState(null); // {type:'group'|'category', editing, groupId, form} const [keywordCat, setKeywordCat] = useState(null); // Kategorie fürs Schlagwort-Popup async function laden() { try { setGroups(await api.listCategories()); } catch (err) { toast(err.message, "error"); } } useEffect(() => { laden(); }, []); function openGroup(editing = null) { setModal({ type: "group", editing, form: editing ? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color } : { name: "", kind: "expense", icon: "list", color: "#3f51b5" }, }); } function openCategory(groupId, editing = null) { setModal({ type: "category", editing, groupId, form: editing ? { name: editing.name, icon: editing.icon, color: editing.color, excluded_from_stats: !!editing.excluded_from_stats, spending_is_good: !!editing.spending_is_good } : { name: "", icon: "more-horizontal", color: "#6b7480", excluded_from_stats: false, spending_is_good: false }, }); } async function speichern(e) { e.preventDefault(); const { type, editing, groupId, form } = modal; try { if (type === "group") { if (editing) await api.updateGroup(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color }); else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color }); } else { const rumpf = { name: form.name.trim(), icon: form.icon, color: form.color, excluded_from_stats: !!form.excluded_from_stats, spending_is_good: !!form.spending_is_good }; if (editing) await api.updateCategory(editing.id, rumpf); else await api.createCategory({ group_id: groupId, ...rumpf }); } setModal(null); laden(); } catch (err) { toast(err.message, "error"); } } async function loeschenGruppe(g) { if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteGroupConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; try { await api.deleteGroup(g.id); laden(); } catch (err) { toast(err.message, "error"); } } async function loeschenKategorie(c) { if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; try { await api.deleteCategory(c.id); laden(); } catch (err) { toast(err.message, "error"); } } return (

{t("categories.title")}

{groups.map((g) => (
{g.name} {g.kind === "income" ? t("categories.income") : t("categories.expense")}
{g.categories.map((c) => (
{c.name} {/* Belegte Kategorien heben sich ab: Akzentfarbe plus Anzahl. Sonst sähe man der Liste nicht an, wo schon etwas hinterlegt ist. */} {c.excluded_from_stats && ( {t("categories.excludedShort")} )}
))} {g.categories.length === 0 && (
{t("categories.emptyGroup")}
)}
))} {groups.length === 0 &&
{t("categories.emptyGroup")}
}
{modal && ( setModal(null)} footer={ <> } >
{modal.type === "group" && !modal.editing && ( )} {modal.type === "category" && ( <>

{t("categories.excludedHint")}

{t("categories.spendingIsGoodHint")}

)}
)} {/* Beim Schliessen neu laden: im Popup kann sich die Anzahl der Schlagwörter geändert haben, und die steht am Knopf. */} {keywordCat && ( { setKeywordCat(null); laden(); }} /> )}
); }