diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
index d10f990..a2af039 100644
--- a/backend/app/routers/dashboard.py
+++ b/backend/app/routers/dashboard.py
@@ -99,47 +99,69 @@ def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
)
+def _units(groups: list[CategoryGroup], by_category: bool):
+ """Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
+ ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
+ for g in groups:
+ if by_category:
+ for c in g.categories:
+ if not c.is_archived:
+ yield (c.name, c.color, [c.id])
+ else:
+ yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
+
+
@router.get("/budget-donut")
-def budget_donut(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
+def budget_donut(
+ group_by: str = Query(default="group"),
+ db: Session = Depends(get_db),
+ user: User = Depends(get_current_user),
+) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label)
groups = _expense_groups(db, user)
+ by_category = group_by == "category"
- segments = []
- total = 0
- for g in groups:
- val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
- if val > 0:
- segments.append({"name": g.name, "value": val, "color": g.color})
- total += val
+ def collect(values: dict[int, int]):
+ segs, tot = [], 0
+ for name, color, ids in _units(groups, by_category):
+ val = sum(values.get(i, 0) for i in ids)
+ if val > 0:
+ segs.append({"name": name, "value": val, "color": color})
+ tot += val
+ return segs, tot
+
+ segments, total = collect(planned)
mode = "planned"
if total == 0:
- actual = budget.actual_expense_by_category(db, user, start, end, base)
- for g in groups:
- val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
- if val > 0:
- segments.append({"name": g.name, "value": val, "color": g.color})
- total += val
+ segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
mode = "actual"
- return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label}
+ return {"segments": segments, "total": total, "currency": base, "mode": mode,
+ "label": label, "group_by": "category" if by_category else "group"}
@router.get("/budget-progress")
-def budget_progress(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
+def budget_progress(
+ group_by: str = Query(default="group"),
+ db: Session = Depends(get_db),
+ user: User = Depends(get_current_user),
+) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label)
actual = budget.actual_expense_by_category(db, user, start, end, base)
+ by_category = group_by == "category"
rows = []
- for g in _expense_groups(db, user):
- gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
- ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
+ for name, color, ids in _units(_expense_groups(db, user), by_category):
+ gp = sum(planned.get(i, 0) for i in ids)
+ ga = sum(actual.get(i, 0) for i in ids)
if gp > 0 or ga > 0:
- rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp})
- return {"rows": rows, "currency": base, "label": label}
+ rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
+ return {"rows": rows, "currency": base, "label": label,
+ "group_by": "category" if by_category else "group"}
@router.get("/income-expense")
diff --git a/web/src/api.js b/web/src/api.js
index 3e965e4..28c780b 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -222,8 +222,8 @@ export const api = {
dashboardLayout: () => request("/dashboard/layout"),
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
- cardBudgetDonut: () => request("/dashboard/budget-donut"),
- cardBudgetProgress: () => request("/dashboard/budget-progress"),
+ cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`),
+ cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`),
cardIncomeExpense: () => request("/dashboard/income-expense"),
cardBalances: () => request("/dashboard/balances"),
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx
index a59d5e6..19697f4 100644
--- a/web/src/dashboard/cards.jsx
+++ b/web/src/dashboard/cards.jsx
@@ -4,7 +4,7 @@ import { api } from "../api";
import { useSettings } from "../settings";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
-import { formatMoney } from "../format";
+import { formatMoney, formatAmount } from "../format";
import { tokenFarbe } from "./palette";
import Donut from "./charts/Donut";
import Line from "./charts/Line";
@@ -53,9 +53,10 @@ function AccountBalances() {
);
}
-function BudgetDonut() {
+function BudgetDonut({ props }) {
const { t } = useTranslation();
- const data = useData(() => api.cardBudgetDonut());
+ const groupBy = props?.group_by || "group";
+ const data = useData(() => api.cardBudgetDonut(groupBy));
if (!data) return ;
if (!data.segments?.length) return
{t("cards.noBudget")}
;
const label = data.mode === "planned" ? t("cards.planned") : t("cards.spent");
@@ -68,12 +69,17 @@ function NetWorth() {
if (!data) return ;
const labels = (data.labels || []).map(shortDate);
const serien = [{ name: t("cards.assets"), color: tokenFarbe("--accent", "#3f51b5"), werte: data.values || [] }];
- return formatMoney(v, data.currency)} />;
+ return (
+ formatMoney(v, data.currency)}
+ achsenFmt={(v) => formatAmount(v, data.currency)} />
+ );
}
-function BudgetProgress() {
+function BudgetProgress({ props }) {
const { t } = useTranslation();
- const data = useData(() => api.cardBudgetProgress());
+ const groupBy = props?.group_by || "group";
+ const data = useData(() => api.cardBudgetProgress(groupBy));
if (!data) return ;
if (!data.rows?.length) return {t("cards.noBudget")}
;
return (
@@ -143,12 +149,22 @@ function RecentTransactions() {
);
}
-// Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße }.
+// Aufschlüsselungs-Option (Gruppen vs. Kategorien) – von Donut und Fortschritt geteilt.
+const AUFSCHLUESSELUNG = {
+ key: "group_by", labelKey: "cards.breakdown", def: "group",
+ choices: [
+ { v: "group", labelKey: "cards.byGroup" },
+ { v: "category", labelKey: "cards.byCategory" },
+ ],
+};
+
+// Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße,
+// optional `options` (im Anordnen-Modus einstellbare Karten-Optionen) }.
export const KARTEN = {
"account-balances": { titleKey: "cards.balances", Comp: AccountBalances, w: 4, h: 5, minW: 3, minH: 3 },
- "budget-donut": { titleKey: "cards.budgetDonut", Comp: BudgetDonut, w: 4, h: 5, minW: 3, minH: 4 },
+ "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 },
+ "budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2, options: [AUFSCHLUESSELUNG] },
"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/dashboard/charts/Donut.jsx b/web/src/dashboard/charts/Donut.jsx
index 235acc5..3ba12a9 100644
--- a/web/src/dashboard/charts/Donut.jsx
+++ b/web/src/dashboard/charts/Donut.jsx
@@ -26,6 +26,11 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
});
const gezeigt = aktiv ?? null;
+ // 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);
+ const wertGroesse = Math.max(11, Math.min(20, Math.floor(190 / Math.max(zentralText.length, 1))));
+
return (
- {def ? createElement(def.Comp) : null}
+
+ {edit && def?.options?.length > 0 && (
+
+ {def.options.map((opt) => {
+ const cur = it.props?.[opt.key] ?? opt.def;
+ return (
+
+
{t(opt.labelKey)}
+
+ {opt.choices.map((ch) => (
+
+ ))}
+
+
+ );
+ })}
+
+ )}
+ {def ? createElement(def.Comp, { key: JSON.stringify(it.props || {}), props: it.props }) : null}
+
);
})}