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.",