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)
|
planned = budget.effective_planned(db, user, label)
|
||||||
by_category = group_by != "group"
|
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 = []
|
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)
|
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)
|
||||||
@@ -173,9 +176,16 @@ def expense_share(
|
|||||||
"planned": geplant,
|
"planned": geplant,
|
||||||
"over": geplant > 0 and val > geplant})
|
"over": geplant > 0 and val > geplant})
|
||||||
rows.sort(key=lambda r: r["amount"], reverse=True)
|
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 {
|
return {
|
||||||
"rows": rows,
|
"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,
|
"currency": base,
|
||||||
"label": label,
|
"label": label,
|
||||||
"group_by": "category" if by_category else "group",
|
"group_by": "category" if by_category else "group",
|
||||||
|
|||||||
@@ -209,3 +209,9 @@ def test_ausgabenanteile_nennen_das_geplante(admin):
|
|||||||
|
|
||||||
plane(max(1, ist - 10000))
|
plane(max(1, ist - 10000))
|
||||||
assert zeile()["over"] is True, "ueberschritten"
|
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") {
|
if ((props?.darstellung || "liste") === "kreis") {
|
||||||
return (
|
return (
|
||||||
<Donut
|
<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")}
|
gesamtLabel={t("cards.spent")}
|
||||||
|
gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null}
|
||||||
fmt={(v) => formatMoney(v, data.currency)}
|
fmt={(v) => formatMoney(v, data.currency)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
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).
|
// 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 [aktiv, setAktiv] = useState(null);
|
||||||
|
|
||||||
const werte = segmente.filter((s) => s.value > 0);
|
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
|
// Mittelbetrag an die Stringlänge anpassen, damit ein 4-stelliger Betrag
|
||||||
// (z. B. „CHF 3'865.00") nicht in den Ring läuft.
|
// (z. B. „CHF 3'865.00") nicht in den Ring läuft.
|
||||||
const zentralText = fmt(gezeigt ? gezeigt.value : summe);
|
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))));
|
const wertGroesse = Math.max(11, Math.min(20, Math.floor(190 / Math.max(zentralText.length, 1))));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +51,8 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
|
|||||||
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
|
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</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 }}>
|
style={{ fontSize: wertGroesse }}>
|
||||||
{zentralText}
|
{zentralText}
|
||||||
</text>
|
</text>
|
||||||
@@ -60,7 +67,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
|
|||||||
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
|
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
|
||||||
<span className="swatch" style={{ background: b.color }} />
|
<span className="swatch" style={{ background: b.color }} />
|
||||||
<span className="legend-name">{b.name}</span>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1443,3 +1443,17 @@ tr.row-dup {
|
|||||||
padding-top: var(--sp-2);
|
padding-top: var(--sp-2);
|
||||||
border-top: 1px dashed var(--border);
|
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