feat: Ringdiagramm faerbt Legende und Mitte nach Budget
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -160,8 +160,13 @@ function ExpenseShare({ props }) {
|
||||
if ((props?.darstellung || "liste") === "kreis") {
|
||||
return (
|
||||
<Donut
|
||||
segmente={data.rows.map((r) => ({ 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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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)} />
|
||||
))}
|
||||
</g>
|
||||
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value"
|
||||
<text x={mitte} y={mitte - 4} textAnchor="middle"
|
||||
className={`donut-value${zentralBudget ? ` budget-${zentralBudget}` : ""}`}
|
||||
style={{ fontSize: wertGroesse }}>
|
||||
{zentralText}
|
||||
</text>
|
||||
@@ -60,7 +67,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
|
||||
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
|
||||
<span className="swatch" style={{ background: b.color }} />
|
||||
<span className="legend-name">{b.name}</span>
|
||||
<span className="legend-value">{fmt(b.value)} · {Math.round(b.anteil * 100)} %</span>
|
||||
<span className={`legend-value${b.budget ? ` budget-${b.budget}` : ""}`}>
|
||||
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user