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 (
setAktiv(b)} onMouseLeave={() => setAktiv(null)} /> ))} - - {fmt(gezeigt ? gezeigt.value : summe)} + + {zentralText} {gezeigt ? gezeigt.name : gesamtLabel} diff --git a/web/src/dashboard/charts/Line.jsx b/web/src/dashboard/charts/Line.jsx index 33d6924..49a9158 100644 --- a/web/src/dashboard/charts/Line.jsx +++ b/web/src/dashboard/charts/Line.jsx @@ -5,7 +5,8 @@ import useGroesse from "./useGroesse"; // Liniendiagramm über die Zeit. // labels: ["01.07.", …] serien: [{ name, color, werte: number[] }] // fmt: Werteformatierer. -export default function Line({ labels = [], serien = [], fmt = (v) => String(v) }) { +export default function Line({ labels = [], serien = [], fmt = (v) => String(v), achsenFmt }) { + const yFmt = achsenFmt || fmt; // Achsen-Ticks dürfen schmaler sein (ohne Währungssymbol) const wirt = useRef(null); const plot = useRef(null); const { breite, hoehe } = useGroesse(plot); @@ -18,7 +19,7 @@ export default function Line({ labels = [], serien = [], fmt = (v) => String(v) return
Keine Daten vorhanden.
; } - const rand = { oben: 12, rechts: 12, unten: 24, links: 52 }; + const rand = { oben: 12, rechts: 12, unten: 24, links: 58 }; const w = Math.max(breite, 240); const h = Math.max(hoehe, 120); const flaecheB = Math.max(w - rand.links - rand.rechts, 10); @@ -71,7 +72,7 @@ export default function Line({ labels = [], serien = [], fmt = (v) => String(v) {gitter.map((g) => ( - {fmt(g.wert)} + {yFmt(g.wert)} ))} {achse.map(({ l, i }) => ( diff --git a/web/src/finance.css b/web/src/finance.css index bd8dc66..4b6eb68 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -420,6 +420,32 @@ tr.budget-total-row td { background: var(--surface-2); } +/* Karten-Optionen im Anordnen-Modus (z. B. Gruppen vs. Kategorien). */ +.dash-config-bar { + display: flex; + flex-wrap: wrap; + gap: var(--sp-3); + padding-bottom: var(--sp-2); + margin-bottom: var(--sp-2); + border-bottom: 1px solid var(--border); +} +.dash-config-opt { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + min-width: 0; + flex: 1 1 140px; +} +.seg.sm { + margin-bottom: 0; + width: 100%; +} +.seg.sm .seg-btn { + padding: 4px 9px; + font-size: 0.78rem; +} + .dash-grid { position: relative; } diff --git a/web/src/locales/de.json b/web/src/locales/de.json index b8a6b5d..1f43410 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -122,7 +122,10 @@ "income": "Einnahmen", "expense": "Ausgaben", "net": "Saldo", - "noBudget": "Noch kein Budget für diesen Monat." + "noBudget": "Noch kein Budget für diesen Monat.", + "breakdown": "Aufschlüsselung", + "byGroup": "Gruppen", + "byCategory": "Kategorien" }, "server": { "unreachable": "Server nicht erreichbar.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 821909b..6137a17 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -122,7 +122,10 @@ "income": "Income", "expense": "Expenses", "net": "Balance", - "noBudget": "No budget for this month yet." + "noBudget": "No budget for this month yet.", + "breakdown": "Breakdown", + "byGroup": "Groups", + "byCategory": "Categories" }, "server": { "unreachable": "Server unreachable.", diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 19d9a77..d64f98b 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -43,12 +43,19 @@ export default function Dashboard() { async function persist(next) { setItems(next); try { - await api.saveDashboardLayout(next.map((it) => ({ i: it.i, type: it.type, x: it.x, y: it.y, w: it.w, h: it.h }))); + await api.saveDashboardLayout(next.map((it) => ({ + i: it.i, type: it.type, x: it.x, y: it.y, w: it.w, h: it.h, props: it.props, + }))); } catch (e) { toast(e.message, "error"); } } + // Eine Karten-Option (z. B. Aufschlüsselung Gruppen/Kategorien) setzen + sichern. + function setCardProp(i, key, value) { + persist(items.map((it) => (it.i === i ? { ...it, props: { ...(it.props || {}), [key]: value } } : it))); + } + function addCard(type) { const def = KARTEN[type]; const maxY = items.reduce((m, it) => Math.max(m, it.y + it.h), 0); @@ -140,7 +147,30 @@ export default function Dashboard() { )}
-
{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} +
); })}