From e10ccdf1857dad4fa6104e763c925d1ad58e7c85 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 09:30:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Dashboard-Karte=20=E2=80=9EAusgaben-Ant?= =?UTF-8?q?eile"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zeigt für den laufenden Budget-Monat, wie sich die tatsächlichen Ausgaben prozentual auf die Kategorien verteilen – absteigend sortiert, mit Balken in der Kategoriefarbe, Prozentwert und Betrag. Anders als der Budget-Donut hängt sie nicht am geplanten Budget: Sie zeigt reine Ist-Ausgaben, auch wenn für den Monat nichts geplant wurde. Umschaltbar zwischen Kategorien (Standard) und Gruppen. Die Anteile rechnet die Oberfläche aus Betrag und Summe – gerundete Prozente vom Server addierten sich sonst nicht auf 100 %. Wird die Karte schmal, gibt zuerst der Betrag nach; ohne Kategorienamen sagt die Zeile nichts mehr aus. Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/dashboard.py | 33 +++++++++++++++++++++++++++++ web/src/api.js | 1 + web/src/dashboard/cards.jsx | 36 ++++++++++++++++++++++++++++++++ web/src/finance.css | 19 +++++++++++++++++ web/src/locales/de.json | 4 +++- web/src/locales/en.json | 4 +++- 6 files changed, 95 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index a2af039..19617e0 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -142,6 +142,39 @@ def budget_donut( "label": label, "group_by": "category" if by_category else "group"} +@router.get("/expense-share") +def expense_share( + group_by: str = Query(default="category"), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + """Tatsächliche Ausgaben des laufenden Budget-Monats je Kategorie/Gruppe. + + Anders als der Budget-Donut zeigt das rein die Ist-Ausgaben – unabhängig + davon, ob für den Monat überhaupt geplant wurde. Die Anteile rechnet die + Oberfläche aus `amount` und `total`, damit sich gerundete Prozente nicht zu + 99,9 % addieren.""" + base = base_currency(db, user) + label = _label(db, user) + start, end = budget.period_bounds(label, _start_day(db, user)) + actual = budget.actual_expense_by_category(db, user, start, end, base) + by_category = group_by != "group" + + rows = [] + for name, color, ids in _units(_expense_groups(db, user), by_category): + val = sum(actual.get(i, 0) for i in ids) + if val > 0: + rows.append({"name": name, "color": color, "amount": val}) + rows.sort(key=lambda r: r["amount"], reverse=True) + return { + "rows": rows, + "total": sum(r["amount"] for r in rows), + "currency": base, + "label": label, + "group_by": "category" if by_category else "group", + } + + @router.get("/budget-progress") def budget_progress( group_by: str = Query(default="group"), diff --git a/web/src/api.js b/web/src/api.js index 36d870e..b024ec4 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -225,6 +225,7 @@ export const api = { saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }), resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }), cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`), + cardExpenseShare: (groupBy) => request(`/dashboard/expense-share${groupBy ? `?group_by=${groupBy}` : ""}`), cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`), cardIncomeExpense: () => request("/dashboard/income-expense"), cardBalances: () => request("/dashboard/balances"), diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 19697f4..4c41a6c 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -149,6 +149,40 @@ function RecentTransactions() { ); } +function ExpenseShare({ props }) { + const { t } = useTranslation(); + const groupBy = props?.group_by || "category"; + const data = useData(() => api.cardExpenseShare(groupBy)); + if (!data) return ; + if (!data.rows?.length) return
{t("cards.noExpenses")}
; + return ( +
+ {data.rows.map((r) => { + // Anteil aus den Rohwerten – gerundete Prozente ergäben in der Summe + // sonst nicht 100 %. + const pct = data.total > 0 ? (r.amount / data.total) * 100 : 0; + return ( +
+
+ ● + {r.name} + {pct.toFixed(1).replace(".", ",")} % + {formatMoney(r.amount, data.currency)} +
+
+
+
+
+ ); + })} +
+ {t("cards.total")} + {formatMoney(data.total, data.currency)} +
+
+ ); +} + // Aufschlüsselungs-Option (Gruppen vs. Kategorien) – von Donut und Fortschritt geteilt. const AUFSCHLUESSELUNG = { key: "group_by", labelKey: "cards.breakdown", def: "group", @@ -165,6 +199,8 @@ export const KARTEN = { "budget-donut": { titleKey: "cards.budgetDonut", Comp: BudgetDonut, w: 4, h: 5, minW: 3, minH: 4, options: [AUFSCHLUESSELUNG] }, "net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3 }, "budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2, options: [AUFSCHLUESSELUNG] }, + "expense-share": { titleKey: "cards.expenseShare", Comp: ExpenseShare, w: 4, h: 5, minW: 3, minH: 3, + options: [{ ...AUFSCHLUESSELUNG, def: "category" }] }, "income-expense": { titleKey: "cards.incomeExpense", Comp: IncomeExpense, w: 4, h: 2, minW: 2, minH: 2 }, "recent-transactions": { titleKey: "cards.recent", Comp: RecentTransactions, w: 4, h: 4, minW: 3, minH: 3 }, }; diff --git a/web/src/finance.css b/web/src/finance.css index 28a0b44..ab2132a 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1087,3 +1087,22 @@ tr.row-dup { margin-top: var(--sp-2); border-top: 1px solid var(--border); } + +/* Ausgaben-Anteile: der Prozentwert ist die Kernaussage der Karte und steht + deshalb vor dem Betrag, mit gleichbleibender Ziffernbreite. */ +.share-pct { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +/* Wird die Karte schmal, gibt zuerst der Betrag nach – ohne den Kategorienamen + sagt die Zeile nichts mehr aus, ohne den Betrag bleibt der Anteil lesbar. */ +.share-row .share-name { + min-width: 4.5rem; +} +.share-row .share-amount { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 6779bd8..39b1bc1 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -132,7 +132,9 @@ "noBudget": "Noch kein Budget für diesen Monat.", "breakdown": "Aufschlüsselung", "byGroup": "Gruppen", - "byCategory": "Kategorien" + "byCategory": "Kategorien", + "expenseShare": "Ausgaben-Anteile", + "noExpenses": "Noch keine Ausgaben in diesem Monat." }, "server": { "unreachable": "Server nicht erreichbar.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 65179f2..9b787cb 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -132,7 +132,9 @@ "noBudget": "No budget for this month yet.", "breakdown": "Breakdown", "byGroup": "Groups", - "byCategory": "Categories" + "byCategory": "Categories", + "expenseShare": "Expense share", + "noExpenses": "No expenses this month yet." }, "server": { "unreachable": "Server unreachable.",