diff --git a/backend/app/routers/budgets.py b/backend/app/routers/budgets.py index 5ea6f3c..6472c20 100644 --- a/backend/app/routers/budgets.py +++ b/backend/app/routers/budgets.py @@ -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) diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index 1f38d23..48ad739 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -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 ------------------------------------------------------------- diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index 65f2b96..8aa4301 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -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() diff --git a/web/src/api.js b/web/src/api.js index 28c780b..525aeba 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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" }), diff --git a/web/src/finance.css b/web/src/finance.css index 4b6eb68..6210c3c 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -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; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 1f43410..75a517f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 6137a17..b980450 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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.", diff --git a/web/src/pages/Budget.jsx b/web/src/pages/Budget.jsx index 59aa061..9897040 100644 --- a/web/src/pages/Budget.jsx +++ b/web/src/pages/Budget.jsx @@ -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
| {i.label} | -{t("budget.planned")}: {fmt(i.planned_cents)} | -- = i.planned_cents ? "pos" : "neg"}`}>{fmt(i.received_cents)} - | -
| {t("budget.totalIncome")} | -{fmt(tot.planned_income)} | -{fmt(tot.received_income)} | -