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>
This commit is contained in:
Guido
2026-08-20 17:07:49 +02:00
co-authored by Claude Opus 4.8
parent 8d0fbdfe2b
commit 49b927e2eb
8 changed files with 262 additions and 81 deletions
+23
View File
@@ -254,3 +254,26 @@ def reset_month(
budget.parse_label(label)
budget.reset_month(db, user, label)
return _build_view(db, user, label)
# --- Kopieren Monat <-> Vorlage --------------------------------------------
@router.post("/template/from-month/{label}")
def template_from_month(
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> dict:
"""„Diesen Monat als Standard festlegen": Monat -> Vorlage."""
budget.parse_label(label)
budget.copy_month_to_template(db, user, label)
return get_template(db, user)
@router.post("/{label}/from-template")
def month_from_template(
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> dict:
"""„Standard in diesen Monat übernehmen": Vorlage -> Monat (überschreibt)."""
budget.parse_label(label)
budget.copy_template_to_month(db, user, label)
return _build_view(db, user, label)
+30
View File
@@ -132,6 +132,36 @@ def reset_month(db: Session, user: User, label: str) -> None:
db.commit()
def copy_month_to_template(db: Session, user: User, label: str) -> None:
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
planned = effective_planned(db, user, label) # {category_id: cents}
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
for category_id, cents in planned.items():
if cents > 0:
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
db.commit()
def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
"""Überschreibt den Monat mit der aktuellen Vorlage (auch wenn er schon
angepasst war): vorhandene Monatszeilen weg, Vorlage frisch übernehmen."""
period = _period(db, user, label)
if period is None:
period = BudgetPeriod(owner_id=user.id, label=label, customized=True)
db.add(period)
db.flush()
else:
for ln in list(period.lines):
db.delete(ln)
db.flush()
period.customized = True
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
period.lines.append(BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents))
db.commit()
return period
# --- Ist-Werte -------------------------------------------------------------
+24
View File
@@ -83,6 +83,30 @@ def test_budget_soll_ist_and_override(admin):
assert v["customized"] is False
def test_budget_copy_month_and_template(admin):
admin.put("/api/settings/budget_start_day", params={"value": "1"})
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
leb = _lebensmittel(admin)
admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 50000})
# Monat individuell auf 700.
admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": 70000})
# Monat -> Vorlage: Vorlage übernimmt den 700er Wert.
r = admin.post(f"/api/budget/template/from-month/{label}")
assert r.status_code == 200, r.text
tpl = r.json()
assert next(l for l in tpl["lines"] if l["category_id"] == leb)["planned_cents"] == 70000
# Vorlage auf 900 ändern, dann Vorlage -> Monat (überschreibt den angepassten Monat).
admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 90000})
v = admin.post(f"/api/budget/{label}/from-template").json()
row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb)
assert row["planned_cents"] == 90000
assert v["customized"] is True
def test_planned_income(admin):
admin.put("/api/settings/budget_start_day", params={"value": "1"})
today = date.today()
+2
View File
@@ -214,6 +214,8 @@ export const api = {
setMonthCategory: (label, catId, cents) =>
request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }),
budgetTemplateFromMonth: (label) => request(`/budget/template/from-month/${label}`, { method: "POST" }),
budgetMonthFromTemplate: (label) => request(`/budget/${label}/from-template`, { method: "POST" }),
createIncome: (body) => request("/budget/income", { method: "POST", body }),
updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }),
deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }),
+54
View File
@@ -278,6 +278,60 @@
white-space: nowrap;
}
/* Nimmt den freien Platz in einer Flex-Zeile und darf schrumpfen (Ellipsis). */
.grow {
flex: 1 1 auto;
min-width: 0;
}
/* Budget: mehrere Monate nebeneinander. */
.budget-months {
display: grid;
gap: var(--sp-4);
grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr));
align-items: start;
}
@media (max-width: 820px) {
.budget-months {
grid-template-columns: 1fr;
}
}
.budget-col {
min-width: 0;
}
.budget-col-head {
display: flex;
align-items: center;
gap: 4px;
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-2);
border-bottom: 2px solid var(--border-strong);
}
.budget-col-head strong {
font-size: 1.05rem;
}
.budget-col-income {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 4px 0 var(--sp-2);
margin-bottom: var(--sp-2);
border-bottom: 1px solid var(--border);
}
.budget-crow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.budget-crow:last-child {
border-bottom: none;
}
.budget-crow .amount.small {
flex: 0 0 auto;
}
/* Budget. */
.budget-navbar {
display: flex;
+7
View File
@@ -236,6 +236,13 @@
"resetTitle": "Monat zurücksetzen",
"resetConfirm": "Diesen Monat auf das Standard-Budget zurücksetzen? Die Anpassungen dieses Monats gehen verloren.",
"templateHint": "Das Standard-Budget gilt für jeden Monat, solange du ihn nicht einzeln anpasst.",
"prevMonth": "Vorheriger Monat",
"nextMonth": "Nächster Monat",
"monthsShown": "Monate:",
"setAsTemplate": "Als Standard festlegen",
"setAsTemplateConfirm": "Die Soll-Werte dieses Monats als neues Standard-Budget übernehmen? Die bisherige Vorlage wird ersetzt (geplante Einnahmen bleiben).",
"copyFromTemplate": "Standard übernehmen",
"copyFromTemplateConfirm": "Diesen Monat mit dem Standard-Budget überschreiben? Die bisherigen Anpassungen dieses Monats gehen verloren.",
"addIncome": "Geplante Einnahme",
"editIncome": "Einnahme bearbeiten",
"noIncome": "Noch keine geplanten Einnahmen.",
+7
View File
@@ -236,6 +236,13 @@
"resetTitle": "Reset month",
"resetConfirm": "Reset this month to the standard budget? This month's adjustments will be lost.",
"templateHint": "The standard budget applies to every month until you adjust a month individually.",
"prevMonth": "Previous month",
"nextMonth": "Next month",
"monthsShown": "Months:",
"setAsTemplate": "Set as standard",
"setAsTemplateConfirm": "Use this month's amounts as the new standard budget? The current template is replaced (planned income stays).",
"copyFromTemplate": "Apply standard",
"copyFromTemplateConfirm": "Overwrite this month with the standard budget? This month's adjustments will be lost.",
"addIncome": "Planned income",
"editIncome": "Edit income",
"noIncome": "No planned income yet.",
+115 -81
View File
@@ -20,6 +20,13 @@ function ProgressBar({ actual, planned }) {
);
}
// "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();
@@ -27,21 +34,24 @@ export default function Budget() {
const toast = useToast();
const [mode, setMode] = useState("month"); // "month" | "template"
const [view, setView] = useState(null);
const [label, setLabel] = useState(null);
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 = view?.base_currency || "EUR";
const base = views?.[0]?.base_currency || "EUR";
const fmt = (c) => formatMoney(c, base);
async function loadView(lbl) {
async function loadViews(anchorLbl, n) {
try {
const v = lbl ? await api.budget(lbl) : await api.budgetCurrent();
setView(v);
setLabel(v.label);
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() {
@@ -54,7 +64,7 @@ export default function Budget() {
} catch (err) { toast(err.message, "error"); }
}
useEffect(() => { loadStamm(); loadView(); }, []);
useEffect(() => { loadStamm(); loadViews(null, 1); }, []);
useEffect(() => { if (mode === "template") loadTemplate(); }, [mode]);
const tplMap = useMemo(
@@ -67,16 +77,32 @@ export default function Budget() {
[groups]
);
async function saveMonth(catId, cents) {
try { setView(await api.setMonthCategory(label, catId, cents)); } catch (err) { toast(err.message, "error"); }
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 resetMonth() {
if (!(await confirm({ title: t("budget.resetTitle"), message: t("budget.resetConfirm"), confirmLabel: t("budget.reset") }))) return;
try { setView(await api.resetMonth(label)); toast(t("settings.saved")); } catch (err) { toast(err.message, "error"); }
}
async function saveIncome(e) {
e.preventDefault();
@@ -92,17 +118,77 @@ export default function Budget() {
else await api.createIncome(body);
setIncomeModal(null);
loadTemplate();
loadView(label);
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(); loadView(label); } catch (err) { toast(err.message, "error"); }
try { await api.deleteIncome(id); loadTemplate(); loadViews(anchor, count); } catch (err) { toast(err.message, "error"); }
}
if (!view) return <div className="muted">{t("common.loading")}</div>;
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>
const tot = view.totals;
{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>
@@ -121,76 +207,24 @@ export default function Budget() {
{mode === "month" ? (
<>
<div className="card budget-navbar">
<button className="btn ghost" onClick={() => loadView(view.prev)}>
<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">{formatMonthLabel(view.label, locale)}</strong>
<button className="btn ghost" onClick={() => loadView(view.next)}>
<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" />
{view.customized && (
<button className="btn sm" onClick={resetMonth}>
<Icon name="refresh-ccw" size={14} /> {t("budget.reset")}
</button>
)}
<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>
{view.income.length > 0 && (
<div className="card">
<h2>{t("budget.income")}</h2>
<table className="table">
<tbody>
{view.income.map((i) => (
<tr key={i.id}>
<td>{i.label}</td>
<td style={{ textAlign: "right" }} className="muted">{t("budget.planned")}: {fmt(i.planned_cents)}</td>
<td style={{ textAlign: "right" }}>
<span className={`amount ${i.received_cents >= i.planned_cents ? "pos" : "neg"}`}>{fmt(i.received_cents)}</span>
</td>
</tr>
))}
<tr className="budget-total-row">
<td><strong>{t("budget.totalIncome")}</strong></td>
<td style={{ textAlign: "right" }} className="muted">{fmt(tot.planned_income)}</td>
<td style={{ textAlign: "right" }}><strong>{fmt(tot.received_income)}</strong></td>
</tr>
</tbody>
</table>
</div>
)}
<div className="card">
<h2>{t("budget.expenses")}</h2>
{view.groups.map((g) => (
<div className="budget-group" key={g.id}>
<div className="budget-group-head">
<IconChip name={g.icon} color={g.color} size={26} />
<span className="grow"><strong>{g.name}</strong></span>
<span className={`amount ${g.over ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
<span className="muted"> / {fmt(g.planned_cents)}</span>
</div>
{g.categories.map((c) => (
<div className="budget-cat" key={c.category_id}>
<div className="budget-cat-name">
<IconChip name={c.icon} color={c.color} size={24} />
<span>{c.name}</span>
</div>
<div className="budget-cat-bar">
<ProgressBar actual={c.actual_cents} planned={c.planned_cents} />
</div>
<span className={`amount budget-actual ${c.over ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
<span className="muted budget-slash">/</span>
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(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 ${tot.actual_expense > tot.planned_expense ? "neg" : ""}`}>{fmt(tot.actual_expense)}</span>
<span className="muted"> / {fmt(tot.planned_expense)}</span>
</div>
<div className="budget-months" style={{ "--cols": views.length }}>
{views.map((v) => <MonthColumn key={v.label} view={v} />)}
</div>
</>
) : (