From 63056ae76aa02a13db2cf8bb7190ff89ddf724a8 Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 14:13:49 +0200 Subject: [PATCH] feat: Ringdiagramm faerbt Legende und Mitte nach Budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eingefaerbt war bisher nur die Listen-Darstellung der Ausgaben-Anteile. In der Kreis-Darstellung blieben die Betraege der Legende grau, und die Summe in der Mitte sagte gar nichts ueber den Stand. Jetzt tragen beide die Aussage: gruen im geplanten Rahmen, rot darueber. Faehrt man ein Segment an, zeigt die Mitte dessen Betrag – dann gilt dort das Budget dieser Kategorie, sonst das des Ganzen. Fuer die Mitte liefert der Server jetzt auch das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur ueber die mit Ausgaben: Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde. Ohne Plan bleibt alles, wie es war. Der Donut faerbt nur, wenn ein Segment die Angabe mitbringt – die Budget-Karte, die dieselbe Komponente benutzt, bleibt also unberuehrt. Der Test hat dabei gleich einen Fehler gefunden: ``_units`` ist ein Generator, und ich lief zweimal darueber – beim zweiten Mal war er leer und das Geplante immer 0. Co-Authored-By: Claude Opus 5 --- backend/app/routers/dashboard.py | 14 ++++++++++++-- backend/tests/test_budget.py | 6 ++++++ web/src/dashboard/cards.jsx | 7 ++++++- web/src/dashboard/charts/Donut.jsx | 17 +++++++++++++---- web/src/finance.css | 14 ++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index db44ebd..7706d61 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -164,8 +164,11 @@ def expense_share( planned = budget.effective_planned(db, user, label) by_category = group_by != "group" + # Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal + # gebraucht – beim zweiten Mal waere er leer und das Geplante 0. + einheiten = list(_units(_expense_groups(db, user), by_category)) rows = [] - for name, color, ids in _units(_expense_groups(db, user), by_category): + for name, color, ids in einheiten: val = sum(actual.get(i, 0) for i in ids) if val > 0: geplant = sum(planned.get(i, 0) for i in ids) @@ -173,9 +176,16 @@ def expense_share( "planned": geplant, "over": geplant > 0 and val > geplant}) rows.sort(key=lambda r: r["amount"], reverse=True) + + # Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben: + # Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde. + gesamt = sum(r["amount"] for r in rows) + gesamt_geplant = sum(planned.get(i, 0) for _, _, ids in einheiten for i in ids) return { "rows": rows, - "total": sum(r["amount"] for r in rows), + "total": gesamt, + "total_planned": gesamt_geplant, + "total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant, "currency": base, "label": label, "group_by": "category" if by_category else "group", diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index 89ca057..46ae966 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -209,3 +209,9 @@ def test_ausgabenanteile_nennen_das_geplante(admin): plane(max(1, ist - 10000)) assert zeile()["over"] is True, "ueberschritten" + + # Und die Mitte des Ringdiagramms bekommt dieselbe Grundlage fuer das + # Ganze – sonst faerbte sich die Legende, die Summe aber nicht. + ganz = admin.get("/api/dashboard/expense-share").json() + assert ganz["total_planned"] > 0 + assert ganz["total_over"] == (ganz["total"] > ganz["total_planned"]) diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 04de542..fd9da69 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -160,8 +160,13 @@ function ExpenseShare({ props }) { if ((props?.darstellung || "liste") === "kreis") { return ( ({ name: r.name, value: r.amount, color: r.color }))} + segmente={data.rows.map((r) => ({ + name: r.name, value: r.amount, color: r.color, + // Ohne Plan keine Aussage – dort gibt es nichts zu überschreiten. + budget: r.planned > 0 ? (r.over ? "over" : "ok") : null, + }))} gesamtLabel={t("cards.spent")} + gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null} fmt={(v) => formatMoney(v, data.currency)} /> ); diff --git a/web/src/dashboard/charts/Donut.jsx b/web/src/dashboard/charts/Donut.jsx index 3ba12a9..3dd6099 100644 --- a/web/src/dashboard/charts/Donut.jsx +++ b/web/src/dashboard/charts/Donut.jsx @@ -1,8 +1,11 @@ import { useState } from "react"; -// Ringdiagramm für Anteile. segmente: [{ name, value, color }] +// Ringdiagramm für Anteile. segmente: [{ name, value, color, budget }] +// `budget` ist "ok", "over" oder fehlt – fehlt es, wird nichts eingefärbt. +// `gesamtBudget` gilt für die Mitte, solange kein Segment angezeigt wird. // fmt: Formatierer für die Werte (z. B. Geldbetrag). -export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) => String(v) }) { +export default function Donut({ segmente = [], gesamtLabel = "gesamt", + gesamtBudget = null, fmt = (v) => String(v) }) { const [aktiv, setAktiv] = useState(null); const werte = segmente.filter((s) => s.value > 0); @@ -29,6 +32,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) // Mittelbetrag an die Stringlänge anpassen, damit ein 4-stelliger Betrag // (z. B. „CHF 3'865.00") nicht in den Ring läuft. const zentralText = fmt(gezeigt ? gezeigt.value : summe); + // Die Mitte zeigt beim Überfahren den Wert EINES Segments – dann gilt dessen + // Budget-Stand, sonst der des Ganzen. + const zentralBudget = gezeigt ? gezeigt.budget : gesamtBudget; const wertGroesse = Math.max(11, Math.min(20, Math.floor(190 / Math.max(zentralText.length, 1)))); return ( @@ -45,7 +51,8 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} /> ))} - {zentralText} @@ -60,7 +67,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
  • setAktiv(b)} onMouseLeave={() => setAktiv(null)}> {b.name} - {fmt(b.value)} · {Math.round(b.anteil * 100)} % + + {fmt(b.value)} · {Math.round(b.anteil * 100)} % +
  • ))} diff --git a/web/src/finance.css b/web/src/finance.css index 368f354..f277e1c 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1443,3 +1443,17 @@ tr.row-dup { padding-top: var(--sp-2); border-top: 1px dashed var(--border); } + +/* Budget-Stand im Ringdiagramm: grün im Rahmen, rot darüber – in der Legende + und in der Mitte. Ohne Plan bleibt beides, wie es war: Da gibt es nichts zu + überschreiten, und eine Farbe behauptete etwas. */ +.legend-value.budget-ok, +.donut-value.budget-ok { + fill: var(--ok); + color: var(--ok); +} +.legend-value.budget-over, +.donut-value.budget-over { + fill: var(--danger); + color: var(--danger); +}