Files
certain/web/src/pages/Budget.jsx
T
GuidoandClaude Opus 4.8 49b927e2eb feat: Budget – mehrere Monate nebeneinander + Kopieren Monat<->Vorlage
- Umschalter 1·2·3: Monate nebeneinander (responsives Raster, auf schmalem
  Schirm gestapelt); prev/next verschiebt das ganze Fenster
- Je Monat: „Als Standard festlegen" (Monat -> Vorlage) und „Standard
  übernehmen" (Vorlage -> Monat, überschreibt), plus Zurücksetzen
- Backend: copy_month_to_template / copy_template_to_month + Endpunkte
  POST /budget/template/from-month/{label} und /budget/{label}/from-template
- Test fürs Kopieren; globale .grow-Utility ergänzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:07:49 +02:00

334 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, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Modal from "../components/Modal";
import EditableAmount from "../components/EditableAmount";
import { formatMoney, formatMonthLabel } from "../format";
function ProgressBar({ actual, planned }) {
const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0;
const over = actual > planned;
return (
<div className="budget-bar">
<div className={`budget-bar-fill${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
</div>
);
}
// "YYYY-MM" um n Monate verschieben (rein clientseitig, für die Nebeneinander-Ansicht).
function shiftLabel(label, n) {
const [y, m] = label.split("-").map(Number);
const d = new Date(y, m - 1 + n, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export default function Budget() {
const { t } = useTranslation();
const { locale } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [mode, setMode] = useState("month"); // "month" | "template"
const [views, setViews] = useState(null); // Array von Monatsansichten (1..3)
const [anchor, setAnchor] = useState(null); // Label des ersten (linken) Monats
const [count, setCount] = useState(1);
const [groups, setGroups] = useState([]);
const [accounts, setAccounts] = useState([]);
const [template, setTemplate] = useState({ lines: [], income: [] });
const [incomeModal, setIncomeModal] = useState(null);
const base = views?.[0]?.base_currency || "EUR";
const fmt = (c) => formatMoney(c, base);
async function loadViews(anchorLbl, n) {
try {
const first = anchorLbl ? await api.budget(anchorLbl) : await api.budgetCurrent();
const rest = [];
for (let k = 1; k < n; k++) rest.push(await api.budget(shiftLabel(first.label, k)));
setViews([first, ...rest]);
setAnchor(first.label);
} catch (err) { toast(err.message, "error"); }
}
async function loadTemplate() {
try { setTemplate(await api.budgetTemplate()); } catch (err) { toast(err.message, "error"); }
}
async function loadStamm() {
try {
const [g, a] = await Promise.all([api.listCategories(), api.listAccounts()]);
setGroups(g); setAccounts(a);
} catch (err) { toast(err.message, "error"); }
}
useEffect(() => { loadStamm(); loadViews(null, 1); }, []);
useEffect(() => { if (mode === "template") loadTemplate(); }, [mode]);
const tplMap = useMemo(
() => Object.fromEntries(template.lines.map((l) => [l.category_id, l.planned_cents])),
[template]
);
const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]);
const incomeCategories = useMemo(
() => groups.filter((g) => g.kind === "income").flatMap((g) => g.categories),
[groups]
);
function replaceView(v) {
setViews((vs) => vs.map((x) => (x.label === v.label ? v : x)));
}
function changeCount(n) { setCount(n); loadViews(anchor, n); }
async function saveMonth(lbl, catId, cents) {
try { replaceView(await api.setMonthCategory(lbl, catId, cents)); } catch (err) { toast(err.message, "error"); }
}
async function resetMonth(lbl) {
if (!(await confirm({ title: t("budget.resetTitle"), message: t("budget.resetConfirm"), confirmLabel: t("budget.reset") }))) return;
try { replaceView(await api.resetMonth(lbl)); toast(t("settings.saved")); } catch (err) { toast(err.message, "error"); }
}
async function copyMonthToTemplate(lbl) {
if (!(await confirm({ title: t("budget.setAsTemplate"), message: t("budget.setAsTemplateConfirm"), confirmLabel: t("budget.setAsTemplate") }))) return;
try { await api.budgetTemplateFromMonth(lbl); toast(t("settings.saved")); if (mode === "template") loadTemplate(); }
catch (err) { toast(err.message, "error"); }
}
async function copyTemplateToMonth(lbl) {
if (!(await confirm({ title: t("budget.copyFromTemplate"), message: t("budget.copyFromTemplateConfirm"), confirmLabel: t("budget.copyFromTemplate"), danger: true }))) return;
try { replaceView(await api.budgetMonthFromTemplate(lbl)); toast(t("settings.saved")); }
catch (err) { toast(err.message, "error"); }
}
async function saveTemplate(catId, cents) {
try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); }
}
async function saveIncome(e) {
e.preventDefault();
const f = incomeModal;
const body = {
label: f.label.trim(),
category_id: f.category_id ? Number(f.category_id) : null,
target_account_id: f.target_account_id ? Number(f.target_account_id) : null,
planned_cents: f.planned_cents,
};
try {
if (f.id) await api.updateIncome(f.id, body);
else await api.createIncome(body);
setIncomeModal(null);
loadTemplate();
loadViews(anchor, count);
} catch (err) { toast(err.message, "error"); }
}
async function deleteIncome(id) {
if (!(await confirm({ title: t("common.delete"), message: t("budget.deleteIncomeConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteIncome(id); loadTemplate(); loadViews(anchor, count); } catch (err) { toast(err.message, "error"); }
}
function MonthColumn({ view: v }) {
const totv = v.totals;
return (
<div className="card budget-col">
<div className="budget-col-head">
<strong className="grow ellipsis">{formatMonthLabel(v.label, locale)}</strong>
<button className="btn sm ghost" title={t("budget.copyFromTemplate")} onClick={() => copyTemplateToMonth(v.label)}>
<Icon name="download" size={14} />
</button>
<button className="btn sm ghost" title={t("budget.setAsTemplate")} onClick={() => copyMonthToTemplate(v.label)}>
<Icon name="upload" size={14} />
</button>
{v.customized && (
<button className="btn sm ghost" title={t("budget.reset")} onClick={() => resetMonth(v.label)}>
<Icon name="refresh-ccw" size={14} />
</button>
)}
</div>
{v.income.length > 0 && (
<div className="budget-col-income">
<span className="grow muted small">{t("budget.income")}</span>
<span className={`amount small ${totv.received_income >= totv.planned_income ? "pos" : "neg"}`}>{fmt(totv.received_income)}</span>
<span className="muted small"> / {fmt(totv.planned_income)}</span>
</div>
)}
{v.groups.map((g) => (
<div className="budget-group" key={g.id}>
<div className="budget-group-head">
<IconChip name={g.icon} color={g.color} size={22} />
<strong className="grow ellipsis">{g.name}</strong>
<span className={`amount small ${g.over ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
<span className="muted small"> / {fmt(g.planned_cents)}</span>
</div>
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} />
{g.categories.map((c) => (
<div className="budget-crow" key={c.category_id}>
<IconChip name={c.icon} color={c.color} size={20} />
<span className="grow ellipsis">{c.name}</span>
<span className={`amount small ${c.over ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(v.label, c.category_id, cents)} />
</div>
))}
</div>
))}
<div className="budget-total-row card-total">
<span className="grow"><strong>{t("budget.totalExpense")}</strong></span>
<span className={`amount ${totv.actual_expense > totv.planned_expense ? "neg" : ""}`}>{fmt(totv.actual_expense)}</span>
<span className="muted"> / {fmt(totv.planned_expense)}</span>
</div>
</div>
);
}
if (!views) return <div className="muted">{t("common.loading")}</div>;
const first = views[0]?.label;
const last = views[views.length - 1]?.label;
const rangeTitle = views.length === 1
? formatMonthLabel(first, locale)
: `${formatMonthLabel(first, locale)} – ${formatMonthLabel(last, locale)}`;
return (
<div>
<div className="page-head">
<h1>{t("budget.title")}</h1>
<div className="seg" style={{ width: 260, marginBottom: 0 }}>
<button type="button" className={`seg-btn${mode === "month" ? " active" : ""}`} onClick={() => setMode("month")}>
{t("budget.monthMode")}
</button>
<button type="button" className={`seg-btn${mode === "template" ? " active" : ""}`} onClick={() => setMode("template")}>
{t("budget.templateMode")}
</button>
</div>
</div>
{mode === "month" ? (
<>
<div className="card budget-navbar">
<button className="btn ghost" onClick={() => loadViews(shiftLabel(anchor, -1), count)} title={t("budget.prevMonth")}>
<Icon name="chevron-right" size={16} className="flip" />
</button>
<strong className="budget-month">{rangeTitle}</strong>
<button className="btn ghost" onClick={() => loadViews(shiftLabel(anchor, 1), count)} title={t("budget.nextMonth")}>
<Icon name="chevron-right" size={16} />
</button>
<div className="grow" />
<span className="muted small">{t("budget.monthsShown")}</span>
<div className="seg sm" style={{ width: "auto" }}>
{[1, 2, 3].map((n) => (
<button key={n} type="button" className={`seg-btn${count === n ? " active" : ""}`} onClick={() => changeCount(n)}>{n}</button>
))}
</div>
</div>
<div className="budget-months" style={{ "--cols": views.length }}>
{views.map((v) => <MonthColumn key={v.label} view={v} />)}
</div>
</>
) : (
<>
<div className="card">
<p className="muted small">{t("budget.templateHint")}</p>
<h2>{t("budget.expenses")}</h2>
{expenseGroups.map((g) => (
<div className="budget-group" key={g.id}>
<div className="budget-group-head">
<IconChip name={g.icon} color={g.color} size={26} />
<strong>{g.name}</strong>
</div>
{g.categories.map((c) => (
<div className="budget-cat" key={c.id}>
<div className="budget-cat-name grow">
<IconChip name={c.icon} color={c.color} size={24} />
<span>{c.name}</span>
</div>
<EditableAmount cents={tplMap[c.id] || 0} onSave={(cents) => saveTemplate(c.id, cents)} />
</div>
))}
</div>
))}
</div>
<div className="card">
<div className="page-head" style={{ marginBottom: "var(--sp-3)" }}>
<h2 style={{ margin: 0 }}>{t("budget.income")}</h2>
<button className="btn sm primary" onClick={() => setIncomeModal({ label: "", category_id: "", target_account_id: "", planned_cents: 0 })}>
<Icon name="plus" size={15} /> {t("budget.addIncome")}
</button>
</div>
{template.income.length === 0 ? (
<div className="muted small">{t("budget.noIncome")}</div>
) : (
<table className="table">
<tbody>
{template.income.map((i) => (
<tr key={i.id}>
<td><strong>{i.label}</strong></td>
<td style={{ textAlign: "right" }}>{formatMoney(i.planned_cents, base)}</td>
<td className="nowrap" style={{ textAlign: "right", width: 80 }}>
<button className="btn sm ghost" onClick={() => setIncomeModal({ ...i, category_id: i.category_id || "", target_account_id: i.target_account_id || "" })}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" onClick={() => deleteIncome(i.id)}>
<Icon name="trash" size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
{incomeModal && (
<Modal
title={incomeModal.id ? t("budget.editIncome") : t("budget.addIncome")}
onClose={() => setIncomeModal(null)}
footer={
<>
<button className="btn" onClick={() => setIncomeModal(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="income-form" type="submit">{t("common.save")}</button>
</>
}
>
<form id="income-form" onSubmit={saveIncome}>
<label>
<span>{t("budget.incomeLabel")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={incomeModal.label} onChange={(e) => setIncomeModal({ ...incomeModal, label: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
<span>{t("budget.incomeCategory")}</span>
<select value={incomeModal.category_id} onChange={(e) => setIncomeModal({ ...incomeModal, category_id: e.target.value })}>
<option value="">—</option>
{incomeCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
<label className="grow">
<span>{t("budget.targetAccount")}</span>
<select value={incomeModal.target_account_id} onChange={(e) => setIncomeModal({ ...incomeModal, target_account_id: e.target.value })}>
<option value="">—</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</label>
</div>
<label>
<span>{t("budget.planned")} ({base})</span>
<input inputMode="decimal" defaultValue={(incomeModal.planned_cents / 100).toFixed(2).replace(".", ",")}
onChange={(e) => {
const cleaned = e.target.value.replace(",", ".");
setIncomeModal((m) => ({ ...m, planned_cents: Math.round((Number.parseFloat(cleaned) || 0) * 100) }));
}} />
</label>
</form>
</Modal>
)}
</div>
);
}