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