feat: Eine Ausgabe, die kein Verlust ist
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt achthundert, bekam eine Warnung fuer etwas Gutes. Ein Haken an der Kategorie stellt das ab. Zwei Folgen: Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn. Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das Sparen aus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
15c2a164ec
commit
ff621984dc
@@ -331,6 +331,18 @@ class Category(Base):
|
|||||||
# weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und
|
# weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und
|
||||||
# Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo.
|
# Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo.
|
||||||
excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Diese Ausgabe ist kein Verlust. Sparen, Rücklagen, Altersvorsorge: Das Geld
|
||||||
|
# ist ausgegeben im Sinne des Budgets – man kann es diesen Monat nicht mehr
|
||||||
|
# verbrauchen –, aber es ist nicht weg.
|
||||||
|
#
|
||||||
|
# Zwei Folgen, und die zweite ist die wichtigere:
|
||||||
|
# 1. Beträge stehen neutral statt rot. NICHT grün: Grün heisst in Certain
|
||||||
|
# überall "Geld kommt rein", und CHF 1'010 in Grün liest sich als
|
||||||
|
# Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn.
|
||||||
|
# 2. Mehr als geplant ist keine Warnung. Wer 1'000 statt 800 zurücklegt,
|
||||||
|
# hat nichts überzogen; ihn deswegen rot anzuschreien ist schlicht
|
||||||
|
# falsch herum.
|
||||||
|
spending_is_good: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
group: Mapped[CategoryGroup] = relationship(back_populates="categories")
|
group: Mapped[CategoryGroup] = relationship(back_populates="categories")
|
||||||
|
|
||||||
|
|||||||
@@ -170,12 +170,26 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
|||||||
cats.append({
|
cats.append({
|
||||||
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color,
|
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color,
|
||||||
"planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
|
"planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
|
||||||
|
# "over" bleibt die nackte Tatsache; ob sie eine Warnung IST,
|
||||||
|
# entscheidet die Kategorie. Wer mehr zuruecklegt als geplant,
|
||||||
|
# hat nichts ueberzogen.
|
||||||
|
"good": c.spending_is_good,
|
||||||
})
|
})
|
||||||
total_planned += g_planned
|
total_planned += g_planned
|
||||||
total_actual += g_actual
|
total_actual += g_actual
|
||||||
|
# Ueberschritten ist eine Gruppe an dem, was WIRKLICH zu viel war. Das
|
||||||
|
# Sparen bleibt draussen: Sonst faerbt sich "Finanzen" rot, weil jemand
|
||||||
|
# mehr zurueckgelegt hat als geplant – eine Warnung fuer etwas Gutes,
|
||||||
|
# und daneben stuende die echte Ueberschreitung ununterscheidbar.
|
||||||
|
# Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das
|
||||||
|
# Sparen aus.
|
||||||
|
echt_geplant = sum(c["planned_cents"] for c in cats if not c["good"])
|
||||||
|
echt_ist = sum(c["actual_cents"] for c in cats if not c["good"])
|
||||||
groups_out.append({
|
groups_out.append({
|
||||||
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
|
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
|
||||||
"planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned,
|
"planned_cents": g_planned, "actual_cents": g_actual,
|
||||||
|
"over": echt_ist > echt_geplant,
|
||||||
|
"good": bool(cats) and all(c["good"] for c in cats),
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ def create_category(
|
|||||||
icon=payload.icon,
|
icon=payload.icon,
|
||||||
color=payload.color,
|
color=payload.color,
|
||||||
excluded_from_stats=payload.excluded_from_stats,
|
excluded_from_stats=payload.excluded_from_stats,
|
||||||
|
spending_is_good=payload.spending_is_good,
|
||||||
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
||||||
)
|
)
|
||||||
db.add(cat)
|
db.add(cat)
|
||||||
@@ -180,7 +181,8 @@ def update_category(
|
|||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
# Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich
|
# Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich
|
||||||
# ein einmal gesetztes Häkchen nie wieder entfernen.
|
# ein einmal gesetztes Häkchen nie wieder entfernen.
|
||||||
if value is not None or key in ("is_archived", "excluded_from_stats"):
|
if value is not None or key in ("is_archived", "excluded_from_stats",
|
||||||
|
"spending_is_good"):
|
||||||
setattr(cat, key, value)
|
setattr(cat, key, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(cat)
|
db.refresh(cat)
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ def _units(groups: list[CategoryGroup], by_category: bool):
|
|||||||
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
||||||
|
|
||||||
|
|
||||||
|
def _kein_verlust(groups: list[CategoryGroup]) -> set[int]:
|
||||||
|
"""Kategorien, deren Ausgabe kein Verlust ist (Sparen, Ruecklagen).
|
||||||
|
|
||||||
|
Ihre Betraege stehen neutral statt rot, und mehr als geplant ist bei ihnen
|
||||||
|
keine Warnung.
|
||||||
|
"""
|
||||||
|
return {c.id for g in groups for c in g.categories if c.spending_is_good}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/budget-donut")
|
@router.get("/budget-donut")
|
||||||
def budget_donut(
|
def budget_donut(
|
||||||
group_by: str = Query(default="group"),
|
group_by: str = Query(default="group"),
|
||||||
@@ -163,15 +172,22 @@ def expense_share(
|
|||||||
|
|
||||||
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
|
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
|
||||||
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
|
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
|
||||||
einheiten = list(_units(_expense_groups(db, user), by_category))
|
gruppen = _expense_groups(db, user)
|
||||||
|
gut = _kein_verlust(gruppen)
|
||||||
|
einheiten = list(_units(gruppen, by_category))
|
||||||
rows = []
|
rows = []
|
||||||
for name, color, ids in einheiten:
|
for name, color, ids in einheiten:
|
||||||
val = sum(actual.get(i, 0) for i in ids)
|
val = sum(actual.get(i, 0) for i in ids)
|
||||||
if val > 0:
|
if val > 0:
|
||||||
geplant = sum(planned.get(i, 0) for i in ids)
|
geplant = sum(planned.get(i, 0) for i in ids)
|
||||||
|
# Ueberschritten ist die Zeile an dem, was WIRKLICH zu viel war –
|
||||||
|
# das Sparen bleibt beim Urteil draussen, nicht beim Betrag.
|
||||||
|
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
|
||||||
|
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
|
||||||
rows.append({"name": name, "color": color, "amount": val,
|
rows.append({"name": name, "color": color, "amount": val,
|
||||||
"planned": geplant,
|
"planned": geplant,
|
||||||
"over": geplant > 0 and val > geplant,
|
"over": echt_geplant > 0 and echt_ist > echt_geplant,
|
||||||
|
"good": bool(ids) and all(i in gut for i in ids),
|
||||||
# Womit sich die Zeile aufschluesseln laesst.
|
# Womit sich die Zeile aufschluesseln laesst.
|
||||||
"ids": list(ids)})
|
"ids": list(ids)})
|
||||||
rows.sort(key=lambda r: r["amount"], reverse=True)
|
rows.sort(key=lambda r: r["amount"], reverse=True)
|
||||||
@@ -251,12 +267,18 @@ def budget_progress(
|
|||||||
planned = budget.effective_planned(db, user, label)
|
planned = budget.effective_planned(db, user, label)
|
||||||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||||
by_category = group_by == "category"
|
by_category = group_by == "category"
|
||||||
|
gruppen = _expense_groups(db, user)
|
||||||
|
gut = _kein_verlust(gruppen)
|
||||||
rows = []
|
rows = []
|
||||||
for name, color, ids in _units(_expense_groups(db, user), by_category):
|
for name, color, ids in _units(gruppen, by_category):
|
||||||
gp = sum(planned.get(i, 0) for i in ids)
|
gp = sum(planned.get(i, 0) for i in ids)
|
||||||
ga = sum(actual.get(i, 0) for i in ids)
|
ga = sum(actual.get(i, 0) for i in ids)
|
||||||
if gp > 0 or ga > 0:
|
if gp > 0 or ga > 0:
|
||||||
rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
|
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
|
||||||
|
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
|
||||||
|
rows.append({"name": name, "color": color, "planned": gp, "actual": ga,
|
||||||
|
"over": echt_ist > echt_geplant,
|
||||||
|
"good": bool(ids) and all(i in gut for i in ids)})
|
||||||
return {"rows": rows, "currency": base, "label": label,
|
return {"rows": rows, "currency": base, "label": label,
|
||||||
"group_by": "category" if by_category else "group"}
|
"group_by": "category" if by_category else "group"}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ class CategoryOut(BaseModel):
|
|||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
is_archived: bool
|
is_archived: bool
|
||||||
excluded_from_stats: bool = False
|
excluded_from_stats: bool = False
|
||||||
|
spending_is_good: bool = False
|
||||||
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
|
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
|
||||||
# Übersicht ohne einen Abruf je Kategorie auskommt.
|
# Übersicht ohne einen Abruf je Kategorie auskommt.
|
||||||
keyword_count: int = 0
|
keyword_count: int = 0
|
||||||
@@ -265,6 +266,7 @@ class CategoryCreate(BaseModel):
|
|||||||
icon: str = "more-horizontal"
|
icon: str = "more-horizontal"
|
||||||
color: str = "#6b7480"
|
color: str = "#6b7480"
|
||||||
excluded_from_stats: bool = False
|
excluded_from_stats: bool = False
|
||||||
|
spending_is_good: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
@@ -275,6 +277,7 @@ class CategoryUpdate(BaseModel):
|
|||||||
position: int | None = None
|
position: int | None = None
|
||||||
is_archived: bool | None = None
|
is_archived: bool | None = None
|
||||||
excluded_from_stats: bool | None = None
|
excluded_from_stats: bool | None = None
|
||||||
|
spending_is_good: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Transaktionen ---------------------------------------------------------
|
# --- Transaktionen ---------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Eine Ausgabe, die kein Verlust ist.
|
||||||
|
|
||||||
|
Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, denn das Geld ist diesen
|
||||||
|
Monat nicht mehr verfuegbar. Es stand danach aber in Rot da, und mehr gespart
|
||||||
|
als geplant galt als Budget-Ueberschreitung. Wer tausend zuruecklegt statt
|
||||||
|
achthundert, bekam eine Warnung fuer etwas Gutes.
|
||||||
|
|
||||||
|
Mit diesem Haken steht der Betrag neutral, und ein Ueberschreiten ist keins.
|
||||||
|
|
||||||
|
Revision ID: 0029
|
||||||
|
Revises: 0028
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0029"
|
||||||
|
down_revision = "0028"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("categories") as batch:
|
||||||
|
batch.add_column(sa.Column("spending_is_good", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.false()))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("categories") as batch:
|
||||||
|
batch.drop_column("spending_is_good")
|
||||||
@@ -253,3 +253,79 @@ def test_uebertrag_am_stueck_haelt_sich_auch_daran(admin):
|
|||||||
assert _ist(admin, sparen) == vorher
|
assert _ist(admin, sparen) == vorher
|
||||||
finally:
|
finally:
|
||||||
_neutral_setzen(admin, sparen, False)
|
_neutral_setzen(admin, sparen, False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Eine Ausgabe, die kein Verlust ist --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sparen_zaehlt_weiter_gilt_aber_nicht_als_ueberschreitung(admin):
|
||||||
|
"""Der Betrag bleibt im Budget – das Geld ist diesen Monat gebunden. Nur
|
||||||
|
die Warnung faellt weg: Wer mehr zuruecklegt als geplant, hat nichts
|
||||||
|
ueberzogen, und Rot waere schlicht falsch herum."""
|
||||||
|
admin.put("/api/settings/budget_start_day", params={"value": "1"})
|
||||||
|
sparen = _kategorie(admin, "Sparen & Investments")
|
||||||
|
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 10000})
|
||||||
|
s = _sparen_szenario(admin, betrag=80000) # deutlich mehr als geplant
|
||||||
|
|
||||||
|
def zeile():
|
||||||
|
v = admin.get("/api/budget/current").json()
|
||||||
|
return next(c for g in v["groups"] for c in g["categories"]
|
||||||
|
if c["category_id"] == sparen)
|
||||||
|
|
||||||
|
ohne = zeile()
|
||||||
|
assert ohne["actual_cents"] >= 80000, "der Betrag muss weiter zaehlen"
|
||||||
|
assert ohne["over"] is True, "die Tatsache bleibt die Tatsache"
|
||||||
|
assert ohne["good"] is False
|
||||||
|
|
||||||
|
r = admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
try:
|
||||||
|
mit = zeile()
|
||||||
|
assert mit["actual_cents"] == ohne["actual_cents"], "Betrag darf sich nicht aendern"
|
||||||
|
assert mit["good"] is True, "die Kategorie meldet sich nicht als 'kein Verlust'"
|
||||||
|
finally:
|
||||||
|
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False})
|
||||||
|
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_gilt_nur_dann_als_kein_verlust_wenn_alle_es_sind(admin):
|
||||||
|
"""Sonst verschwindet eine echte Ueberschreitung hinter dem Sparen, das
|
||||||
|
zufaellig in derselben Gruppe liegt."""
|
||||||
|
sparen = _kategorie(admin, "Sparen & Investments")
|
||||||
|
r = admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
try:
|
||||||
|
v = admin.get("/api/budget/current").json()
|
||||||
|
gruppe = next(g for g in v["groups"]
|
||||||
|
if any(c["category_id"] == sparen for c in g["categories"]))
|
||||||
|
andere = [c for c in gruppe["categories"] if c["category_id"] != sparen]
|
||||||
|
if andere:
|
||||||
|
assert gruppe["good"] is False, "eine gemischte Gruppe ist nicht 'kein Verlust'"
|
||||||
|
assert next(c for c in gruppe["categories"]
|
||||||
|
if c["category_id"] == sparen)["good"] is True
|
||||||
|
finally:
|
||||||
|
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False})
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_wird_nicht_rot_weil_jemand_zu_viel_gespart_hat(admin):
|
||||||
|
"""Die Ueberschreitung einer Gruppe misst sich an dem, was wirklich zu viel
|
||||||
|
war. Sonst faerbt sich "Finanzen" rot, weil mehr zurueckgelegt wurde als
|
||||||
|
geplant – und die echte Ueberschreitung daneben faellt nicht mehr auf."""
|
||||||
|
admin.put("/api/settings/budget_start_day", params={"value": "1"})
|
||||||
|
sparen = _kategorie(admin, "Sparen & Investments")
|
||||||
|
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 10000})
|
||||||
|
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True})
|
||||||
|
try:
|
||||||
|
_sparen_szenario(admin, betrag=90000) # weit ueber dem Plan
|
||||||
|
v = admin.get("/api/budget/current").json()
|
||||||
|
gruppe = next(g for g in v["groups"]
|
||||||
|
if any(c["category_id"] == sparen for c in g["categories"]))
|
||||||
|
zeile = next(c for c in gruppe["categories"] if c["category_id"] == sparen)
|
||||||
|
|
||||||
|
assert zeile["over"] is True, "die Tatsache bleibt stehen"
|
||||||
|
assert zeile["good"] is True
|
||||||
|
assert gruppe["actual_cents"] >= 90000, "die Summe zeigt weiter alles"
|
||||||
|
assert gruppe["over"] is False, "Gruppe rot wegen des Sparens"
|
||||||
|
finally:
|
||||||
|
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False})
|
||||||
|
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 0})
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ function AccountBalances() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ist das Ueberschreiten dieser Zeile eine Warnung? Bei Sparen und Ruecklagen
|
||||||
|
// nicht: Wer mehr zurueckgelegt hat als geplant, hat nichts ueberzogen.
|
||||||
|
function warnt(r) {
|
||||||
|
return r.over && !r.good;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farbe des Betrags. "good" schlaegt beides: nicht rot (keine Warnung), aber
|
||||||
|
// auch nicht gruen – gruen heisst hier ueberall "Geld kommt rein".
|
||||||
|
function tonFuer(r) {
|
||||||
|
if (r.good) return "muted";
|
||||||
|
if (!(r.planned > 0)) return "muted";
|
||||||
|
return r.over ? "neg" : "pos";
|
||||||
|
}
|
||||||
|
|
||||||
function BudgetDonut({ props }) {
|
function BudgetDonut({ props }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const groupBy = props?.group_by || "group";
|
const groupBy = props?.group_by || "group";
|
||||||
@@ -94,11 +108,11 @@ function BudgetProgress({ props }) {
|
|||||||
<div className="dash-progress-top">
|
<div className="dash-progress-top">
|
||||||
<span className="ellipsis" style={{ color: r.color }}>●</span>
|
<span className="ellipsis" style={{ color: r.color }}>●</span>
|
||||||
<span className="grow ellipsis">{r.name}</span>
|
<span className="grow ellipsis">{r.name}</span>
|
||||||
<span className={`amount small ${r.over ? "neg" : "muted"}`}>{formatMoney(r.actual, data.currency)}</span>
|
<span className={`amount small ${warnt(r) ? "neg" : "muted"}`}>{formatMoney(r.actual, data.currency)}</span>
|
||||||
<span className="muted small"> / {formatMoney(r.planned, data.currency)}</span>
|
<span className="muted small"> / {formatMoney(r.planned, data.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="budget-bar">
|
<div className="budget-bar">
|
||||||
<div className={`budget-bar-fill${r.over ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
<div className={`budget-bar-fill${warnt(r) ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -239,7 +253,7 @@ function ExpenseShare({ props }) {
|
|||||||
segmente={data.rows.map((r) => ({
|
segmente={data.rows.map((r) => ({
|
||||||
name: r.name, value: r.amount, color: r.color,
|
name: r.name, value: r.amount, color: r.color,
|
||||||
// Ohne Plan keine Aussage – dort gibt es nichts zu überschreiten.
|
// Ohne Plan keine Aussage – dort gibt es nichts zu überschreiten.
|
||||||
budget: r.planned > 0 ? (r.over ? "over" : "ok") : null,
|
budget: r.planned > 0 && !r.good ? (r.over ? "over" : "ok") : null,
|
||||||
}))}
|
}))}
|
||||||
gesamtLabel={t("cards.spent")}
|
gesamtLabel={t("cards.spent")}
|
||||||
gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null}
|
gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null}
|
||||||
@@ -271,8 +285,7 @@ function ExpenseShare({ props }) {
|
|||||||
{/* Grün im geplanten Rahmen, rot darüber – dieselben Farben wie
|
{/* Grün im geplanten Rahmen, rot darüber – dieselben Farben wie
|
||||||
überall sonst für Einnahme und Ausgabe. Ohne Plan bleibt der
|
überall sonst für Einnahme und Ausgabe. Ohne Plan bleibt der
|
||||||
Betrag gedeckt: Da gibt es nichts zu überschreiten. */}
|
Betrag gedeckt: Da gibt es nichts zu überschreiten. */}
|
||||||
<span className={`amount small share-amount ${
|
<span className={`amount small share-amount ${tonFuer(r)}`}>
|
||||||
r.planned > 0 ? (r.over ? "neg" : "pos") : "muted"}`}>
|
|
||||||
{formatMoney(r.amount, data.currency)}
|
{formatMoney(r.amount, data.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +293,7 @@ function ExpenseShare({ props }) {
|
|||||||
{/* Der Balken IST die Ausgabe – er trägt die Aussage mit, damit
|
{/* Der Balken IST die Ausgabe – er trägt die Aussage mit, damit
|
||||||
man sie sieht, ohne die Zahl zu lesen. Ohne Plan bleibt die
|
man sie sieht, ohne die Zahl zu lesen. Ohne Plan bleibt die
|
||||||
Kategoriefarbe: Da gibt es nichts zu überschreiten. */}
|
Kategoriefarbe: Da gibt es nichts zu überschreiten. */}
|
||||||
<div className={`budget-bar-fill${r.planned > 0 && r.over ? " over" : ""}`}
|
<div className={`budget-bar-fill${r.planned > 0 && warnt(r) ? " over" : ""}`}
|
||||||
style={{ width: `${pct}%`,
|
style={{ width: `${pct}%`,
|
||||||
...(r.planned > 0 ? {} : { background: r.color }) }} />
|
...(r.planned > 0 ? {} : { background: r.color }) }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -246,6 +246,8 @@
|
|||||||
"searchPlaceholder": "Kategorie suchen …",
|
"searchPlaceholder": "Kategorie suchen …",
|
||||||
"excludedFromStats": "Nicht in Auswertungen einrechnen",
|
"excludedFromStats": "Nicht in Auswertungen einrechnen",
|
||||||
"excludedHint": "Für Umbuchungen zwischen eigenen Konten: Der Kontostand ändert sich, aber Budget, Dashboard und Statistik lassen diese Buchungen außen vor – das Geld ist ja nicht ausgegeben.",
|
"excludedHint": "Für Umbuchungen zwischen eigenen Konten: Der Kontostand ändert sich, aber Budget, Dashboard und Statistik lassen diese Buchungen außen vor – das Geld ist ja nicht ausgegeben.",
|
||||||
|
"spendingIsGood": "Diese Ausgabe ist kein Verlust",
|
||||||
|
"spendingIsGoodHint": "Für Sparen, Rücklagen und Vorsorge: Der Betrag zählt weiter im Budget – das Geld ist diesen Monat gebunden –, steht aber neutral statt rot. Und mehr zurückzulegen als geplant gilt nicht als Überschreitung.",
|
||||||
"excludedShort": "neutral",
|
"excludedShort": "neutral",
|
||||||
"applyRules": "Regeln nachträglich anwenden",
|
"applyRules": "Regeln nachträglich anwenden",
|
||||||
"applyRulesHint": "Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die Regel gab –, liegt noch ohne Kategorie da. Hier holst du das nach.",
|
"applyRulesHint": "Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die Regel gab –, liegt noch ohne Kategorie da. Hier holst du das nach.",
|
||||||
|
|||||||
@@ -246,6 +246,8 @@
|
|||||||
"searchPlaceholder": "Search category …",
|
"searchPlaceholder": "Search category …",
|
||||||
"excludedFromStats": "Exclude from reports",
|
"excludedFromStats": "Exclude from reports",
|
||||||
"excludedHint": "For transfers between your own accounts: the balance still changes, but budget, dashboard and statistics leave these out – the money was not spent.",
|
"excludedHint": "For transfers between your own accounts: the balance still changes, but budget, dashboard and statistics leave these out – the money was not spent.",
|
||||||
|
"spendingIsGood": "This spending is not a loss",
|
||||||
|
"spendingIsGoodHint": "For savings and reserves: the amount still counts towards the budget – the money is committed this month – but it is shown neutrally instead of red, and putting aside more than planned is not treated as overspending.",
|
||||||
"excludedShort": "neutral",
|
"excludedShort": "neutral",
|
||||||
"applyRules": "Apply rules to existing transactions",
|
"applyRules": "Apply rules to existing transactions",
|
||||||
"applyRulesHint": "Rules apply on import. Anything that came in earlier – or before the rule existed – still sits there without a category. This catches up.",
|
"applyRulesHint": "Rules apply on import. Anything that came in earlier – or before the rule existed – still sits there without a category. This catches up.",
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import Modal from "../components/Modal";
|
|||||||
import EditableAmount from "../components/EditableAmount";
|
import EditableAmount from "../components/EditableAmount";
|
||||||
import { formatMoney, formatMonthLabel } from "../format";
|
import { formatMoney, formatMonthLabel } from "../format";
|
||||||
|
|
||||||
function ProgressBar({ actual, planned }) {
|
function ProgressBar({ actual, planned, over }) {
|
||||||
const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0;
|
const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0;
|
||||||
const over = actual > planned;
|
// Ob ueberschritten wurde, sagt der Server – er laesst dabei das Sparen aus,
|
||||||
|
// und das ist eine Rechnung, die hier niemand nachbauen sollte. Ohne Angabe
|
||||||
|
// (andere Aufrufer) bleibt es beim schlichten Vergleich.
|
||||||
|
const rot = over ?? actual > planned;
|
||||||
return (
|
return (
|
||||||
<div className="budget-bar">
|
<div className="budget-bar">
|
||||||
<div className={`budget-bar-fill${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
<div className={`budget-bar-fill${rot ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,15 +161,15 @@ export default function Budget() {
|
|||||||
<div className="budget-group-head">
|
<div className="budget-group-head">
|
||||||
<IconChip name={g.icon} color={g.color} size={22} />
|
<IconChip name={g.icon} color={g.color} size={22} />
|
||||||
<strong className="grow ellipsis">{g.name}</strong>
|
<strong className="grow ellipsis">{g.name}</strong>
|
||||||
<span className={`amount small ${g.over ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
|
<span className={`amount small ${g.over && !g.good ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
|
||||||
<span className="muted small"> / {fmt(g.planned_cents)}</span>
|
<span className="muted small"> / {fmt(g.planned_cents)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} />
|
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} over={g.over} />
|
||||||
{g.categories.map((c) => (
|
{g.categories.map((c) => (
|
||||||
<div className="budget-crow" key={c.category_id}>
|
<div className="budget-crow" key={c.category_id}>
|
||||||
<IconChip name={c.icon} color={c.color} size={20} />
|
<IconChip name={c.icon} color={c.color} size={20} />
|
||||||
<span className="grow ellipsis">{c.name}</span>
|
<span className="grow ellipsis">{c.name}</span>
|
||||||
<span className={`amount small ${c.over ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</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)} />
|
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(v.label, c.category_id, cents)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -197,8 +197,10 @@ export default function Categories() {
|
|||||||
groupId,
|
groupId,
|
||||||
form: editing
|
form: editing
|
||||||
? { name: editing.name, icon: editing.icon, color: editing.color,
|
? { name: editing.name, icon: editing.icon, color: editing.color,
|
||||||
excluded_from_stats: !!editing.excluded_from_stats }
|
excluded_from_stats: !!editing.excluded_from_stats,
|
||||||
: { name: "", icon: "more-horizontal", color: "#6b7480", excluded_from_stats: false },
|
spending_is_good: !!editing.spending_is_good }
|
||||||
|
: { name: "", icon: "more-horizontal", color: "#6b7480",
|
||||||
|
excluded_from_stats: false, spending_is_good: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +213,8 @@ export default function Categories() {
|
|||||||
else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color });
|
else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color });
|
||||||
} else {
|
} else {
|
||||||
const rumpf = { name: form.name.trim(), icon: form.icon, color: form.color,
|
const rumpf = { name: form.name.trim(), icon: form.icon, color: form.color,
|
||||||
excluded_from_stats: !!form.excluded_from_stats };
|
excluded_from_stats: !!form.excluded_from_stats,
|
||||||
|
spending_is_good: !!form.spending_is_good };
|
||||||
if (editing) await api.updateCategory(editing.id, rumpf);
|
if (editing) await api.updateCategory(editing.id, rumpf);
|
||||||
else await api.createCategory({ group_id: groupId, ...rumpf });
|
else await api.createCategory({ group_id: groupId, ...rumpf });
|
||||||
}
|
}
|
||||||
@@ -339,6 +342,12 @@ export default function Categories() {
|
|||||||
<span>{t("categories.excludedFromStats")}</span>
|
<span>{t("categories.excludedFromStats")}</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.excludedHint")}</p>
|
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.excludedHint")}</p>
|
||||||
|
<label className="checkline">
|
||||||
|
<input type="checkbox" checked={!!modal.form.spending_is_good}
|
||||||
|
onChange={(e) => setModal({ ...modal, form: { ...modal.form, spending_is_good: e.target.checked } })} />
|
||||||
|
<span>{t("categories.spendingIsGood")}</span>
|
||||||
|
</label>
|
||||||
|
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.spendingIsGoodHint")}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
Reference in New Issue
Block a user