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>
376 lines
15 KiB
React
376 lines
15 KiB
React
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>
|
||
);
|
||
}
|