Files
certain/web/src/pages/Categories.jsx
T
ScarriffleandClaude Opus 5 ff621984dc feat: Eine Ausgabe, die kein Verlust ist
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat
gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt
achthundert, bekam eine Warnung fuer etwas Gutes.

Ein Haken an der Kategorie stellt das ab. Zwei Folgen:

Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld
kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau
das Richtige – kein Verlust, kein Gewinn.

Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene
hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt
sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung
daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur
das Urteil laesst das Sparen aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:28:36 +02:00

376 lines
15 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<Modal
title={t("categories.keywordsTitle", { name: category.name })}
onClose={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={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={beiTaste}
/>
<input
value={amount}
inputMode="decimal"
placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={beiTaste}
/>
<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) => (
<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")}
</p>
</>
)}
</Modal>
);
}
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 (
<div>
<div className="page-head">
<h1>{t("categories.title")}</h1>
<button className="btn primary" onClick={() => openGroup()}>
<Icon name="plus" size={16} /> {t("categories.newGroup")}
</button>
</div>
<div className="card">
{groups.map((g) => (
<div className="cat-section" key={g.id}>
<div className="cat-section-head">
<IconChip name={g.icon} color={g.color} size={24} />
<span className="cat-section-name">{g.name}</span>
<span className={`badge ${g.kind === "income" ? "ok" : ""}`}>
{g.kind === "income" ? t("categories.income") : t("categories.expense")}
</span>
<button className="btn sm ghost" title={t("categories.newCategory")} onClick={() => openCategory(g.id)}>
<Icon name="plus" size={15} />
</button>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openGroup(g)}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenGruppe(g)}>
<Icon name="trash" size={15} />
</button>
</div>
{g.categories.map((c) => (
<div className="cat-row" key={c.id}>
<IconChip name={c.icon} color={c.color} size={26} />
<span className="grow">{c.name}</span>
{/* Belegte Kategorien heben sich ab: Akzentfarbe plus Anzahl.
Sonst sähe man der Liste nicht an, wo schon etwas hinterlegt ist. */}
<button
className={c.keyword_count ? "btn sm ghost kw-btn belegt" : "btn sm ghost kw-btn"}
title={c.keyword_count
? t("categories.keywordsCount", { count: c.keyword_count })
: t("categories.keywordsBtn")}
onClick={() => setKeywordCat(c)}
>
<Icon name="tags" size={14} />
{c.keyword_count ? <span className="kw-btn-zahl">{c.keyword_count}</span> : null}
</button>
{c.excluded_from_stats && (
<span className="badge" title={t("categories.excludedHint")}>{t("categories.excludedShort")}</span>
)}
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenKategorie(c)}>
<Icon name="trash" size={14} />
</button>
</div>
))}
{g.categories.length === 0 && (
<div className="muted small" style={{ padding: "var(--sp-2) 4px" }}>{t("categories.emptyGroup")}</div>
)}
</div>
))}
{groups.length === 0 && <div className="empty-state">{t("categories.emptyGroup")}</div>}
</div>
{modal && (
<Modal
title={
modal.type === "group"
? (modal.editing ? t("categories.editGroup") : t("categories.newGroup"))
: (modal.editing ? t("categories.editCategory") : t("categories.newCategory"))
}
onClose={() => setModal(null)}
footer={
<>
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="cat-form" type="submit">{t("common.save")}</button>
</>
}
>
<form id="cat-form" onSubmit={speichern}>
<label>
<span>{t("categories.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={modal.form.name}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })} required />
</label>
{modal.type === "group" && !modal.editing && (
<label>
<span>{t("categories.kind")}</span>
<select value={modal.form.kind}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, kind: e.target.value } })}>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
</select>
</label>
)}
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={CATEGORY_ICONS} value={modal.form.icon} color={modal.form.color}
onChange={(name) => setModal({ ...modal, form: { ...modal.form, icon: name } })} />
</label>
{modal.type === "category" && (
<>
<label className="checkline">
<input type="checkbox" checked={!!modal.form.excluded_from_stats}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, excluded_from_stats: e.target.checked } })} />
<span>{t("categories.excludedFromStats")}</span>
</label>
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.excludedHint")}</p>
<label className="checkline">
<input type="checkbox" checked={!!modal.form.spending_is_good}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, spending_is_good: e.target.checked } })} />
<span>{t("categories.spendingIsGood")}</span>
</label>
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.spendingIsGoodHint")}</p>
</>
)}
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input type="color" value={modal.form.color}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })} />
<IconChip name={modal.form.icon} color={modal.form.color} />
</div>
</label>
</form>
</Modal>
)}
{/* Beim Schliessen neu laden: im Popup kann sich die Anzahl der
Schlagwörter geändert haben, und die steht am Knopf. */}
{keywordCat && (
<KeywordModal
category={keywordCat}
onClose={() => { setKeywordCat(null); laden(); }}
/>
)}
</div>
);
}