feat: Der Soll-Betrag kann sich aus den Vertraegen rechnen

Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.

Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:

  fest        eine Zahl, die man hinschreibt. Wie bisher.
  berechnet   Summe der Vertraege dieser Kategorie plus einem Zuschlag.

Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.

Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.

Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.

Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.

Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 20:49:21 +02:00
co-authored by Claude Opus 5
parent 26718b1301
commit ab5d18e0d0
11 changed files with 613 additions and 31 deletions
+8
View File
@@ -329,6 +329,14 @@ export const api = {
budgetTemplate: () => request("/budget/template"),
setTemplateCategory: (catId, cents) =>
request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
// Betriebsart und Zuschlag einer Budget-Zeile. Getrennt vom Soll-Betrag,
// weil bei "berechnet" der Betrag gar nicht mehr gesetzt wird.
setTemplateModus: (catId, modus, zuschlagCents) =>
request(`/budget/template/category/${catId}?modus=${modus}&zuschlag_cents=${zuschlagCents}`,
{ method: "PUT" }),
setMonthModus: (label, catId, modus, zuschlagCents) =>
request(`/budget/${label}/category/${catId}?modus=${modus}&zuschlag_cents=${zuschlagCents}`,
{ method: "PUT" }),
setMonthCategory: (label, catId, cents) =>
request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }),
+33
View File
@@ -1859,3 +1859,36 @@ tr.row-dup {
.griff-n, .griff-s { left: calc(50% - 10px); }
.griff-w, .griff-e { top: calc(50% - 10px); }
}
/* --- Budget aus Verträgen ------------------------------------------------ */
/* Die Rechnung unter der Zeile. Eingerückt bis unter den Namen, damit sie zur
Zeile darüber gehört und nicht wie eine eigene Kategorie aussieht. */
.budget-rechnung {
padding: 0 0 6px 30px;
margin-top: -4px;
font-variant-numeric: tabular-nums;
}
.budget-hinweis { margin: 0 0 var(--sp-2); }
/* Der Umschalter. Schmal – er steht zwischen Ist-Betrag und Eingabefeld, und
dort ist wenig Platz. */
.btn-sigma {
flex: none;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.btn-sigma:hover { border-color: var(--border-strong); color: var(--fg); }
.btn-sigma.an {
border-color: var(--accent, var(--border-strong));
color: var(--accent, var(--fg));
background: var(--accent-soft, transparent);
}
+11 -1
View File
@@ -321,7 +321,17 @@
"deleteIncomeConfirm": "Diese geplante Einnahme löschen?",
"incomeLabel": "Bezeichnung",
"incomeCategory": "Kategorie",
"targetAccount": "Zielkonto"
"targetAccount": "Zielkonto",
"modusBerechnet": "Aus Verträgen rechnen",
"modusFest": "Festen Betrag setzen",
"surchargePlaceholder": "Zuschlag",
"contractsWithoutRate": "Für {{waehrungen}} liegt kein Kurs vor. Verträge in dieser Währung fehlen in der Rechnung.",
"fromContracts_one": "{{betrag}} aus einem Vertrag",
"fromContracts_other": "{{betrag}} aus {{count}} Verträgen",
"boundByContracts_one": "davon {{betrag}} fest gebunden (ein Vertrag)",
"boundByContracts_other": "davon {{betrag}} fest gebunden ({{count}} Verträge)",
"contractsWithoutCategory_one": "Ein Vertrag über {{betrag}} im Monat hat keine Kategorie und fehlt deshalb im Budget.",
"contractsWithoutCategory_other": "{{count}} Verträge über {{betrag}} im Monat haben keine Kategorie und fehlen deshalb im Budget."
},
"abos": {
"title": "Abos & Verträge",
+11 -1
View File
@@ -321,7 +321,17 @@
"deleteIncomeConfirm": "Delete this planned income?",
"incomeLabel": "Label",
"incomeCategory": "Category",
"targetAccount": "Target account"
"targetAccount": "Target account",
"modusBerechnet": "Compute from contracts",
"modusFest": "Set a fixed amount",
"surchargePlaceholder": "Extra",
"contractsWithoutRate": "No exchange rate for {{waehrungen}}. Contracts in that currency are missing from the calculation.",
"fromContracts_one": "{{betrag}} from one contract",
"fromContracts_other": "{{betrag}} from {{count}} contracts",
"boundByContracts_one": "{{betrag}} of it is committed (one contract)",
"boundByContracts_other": "{{betrag}} of it is committed ({{count}} contracts)",
"contractsWithoutCategory_one": "One contract worth {{betrag}} per month has no category and is missing from the budget.",
"contractsWithoutCategory_other": "{{count}} contracts worth {{betrag}} per month have no category and are missing from the budget."
},
"abos": {
"title": "Subscriptions & contracts",
+78 -7
View File
@@ -103,6 +103,21 @@ export default function Budget() {
catch (err) { toast(err.message, "error"); }
}
// Betriebsart umschalten. Beim Wechsel auf "berechnet" wandert der bisherige
// Soll-Betrag NICHT in den Zuschlag: Er steckt ja groesstenteils schon in den
// Vertraegen, und ihn zu addieren verdoppelte sie stillschweigend.
async function saveModus(lbl, c, modus) {
const zuschlag = modus === "berechnet" ? c.zuschlag_cents : 0;
try {
replaceView(await api.setMonthModus(lbl, c.category_id, modus, zuschlag));
} catch (err) { toast(err.message, "error"); }
}
async function saveZuschlag(lbl, c, cents) {
try {
replaceView(await api.setMonthModus(lbl, c.category_id, "berechnet", cents));
} catch (err) { toast(err.message, "error"); }
}
async function saveTemplate(catId, cents) {
try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); }
}
@@ -156,6 +171,24 @@ export default function Budget() {
</div>
)}
{/* Was sich nicht zuordnen liess. Ohne diesen Hinweis fehlt das Geld
im Budget, ohne dass irgendwo steht, dass es fehlt. */}
{v.vertraege?.ohne_kategorie_anzahl > 0 && (
<p className="muted warn small budget-hinweis">
{t("budget.contractsWithoutCategory", {
count: v.vertraege.ohne_kategorie_anzahl,
betrag: fmt(v.vertraege.ohne_kategorie_cents),
})}
</p>
)}
{v.vertraege?.ohne_kurs?.length > 0 && (
<p className="muted warn small budget-hinweis">
{t("budget.contractsWithoutRate", {
waehrungen: v.vertraege.ohne_kurs.join(", "),
})}
</p>
)}
{v.groups.map((g) => (
<div className="budget-group" key={g.id}>
<div className="budget-group-head">
@@ -165,14 +198,52 @@ export default function Budget() {
<span className="muted small"> / {fmt(g.planned_cents)}</span>
</div>
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} over={g.over} />
{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 && !c.good ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(v.label, c.category_id, cents)} />
{g.categories.map((c) => {
const berechnet = c.modus === "berechnet";
return (
<div key={c.category_id}>
<div className="budget-crow">
<IconChip name={c.icon} color={c.color} size={20} />
<span className="grow ellipsis">{c.name}</span>
<span className={`amount small ${c.over && !c.good ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
{/* Der Schalter steht nur da, wo es etwas zu rechnen gibt –
bei einer Kategorie ohne Vertraege waere er ein Angebot,
das nichts tut. */}
{(c.vertraege_anzahl > 0 || berechnet) && (
<button type="button"
className={`btn-sigma${berechnet ? " an" : ""}`}
title={t(berechnet ? "budget.modusFest" : "budget.modusBerechnet")}
onClick={() => saveModus(v.label, c, berechnet ? "fest" : "berechnet")}>
&#931;
</button>
)}
<EditableAmount
cents={berechnet ? c.zuschlag_cents : c.planned_cents}
placeholder={berechnet ? t("budget.surchargePlaceholder") : "0,00"}
onSave={(cents) => (berechnet
? saveZuschlag(v.label, c, cents)
: saveMonth(v.label, c.category_id, cents))} />
</div>
{/* Die Rechnung offenlegen. Ohne sie stuende im Feld eine Zahl,
die nicht der Soll-Betrag ist – und niemand wuesste, warum. */}
{berechnet ? (
<div className="budget-rechnung muted small">
{t("budget.fromContracts", {
betrag: fmt(c.aus_vertraegen_cents), count: c.vertraege_anzahl,
})}
{" + "}{fmt(c.zuschlag_cents)}{" = "}
<strong>{fmt(c.planned_cents)}</strong>
</div>
) : c.vertraege_anzahl > 0 ? (
<div className="budget-rechnung muted small">
{t("budget.boundByContracts", {
betrag: fmt(c.aus_vertraegen_cents), count: c.vertraege_anzahl,
})}
</div>
) : null}
</div>
))}
);
})}
</div>
))}