feat: Dashboard-Karte „Ausgaben-Anteile"
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 <noreply@anthropic.com>
This commit is contained in:
@@ -142,6 +142,39 @@ def budget_donut(
|
|||||||
"label": label, "group_by": "category" if by_category else "group"}
|
"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")
|
@router.get("/budget-progress")
|
||||||
def budget_progress(
|
def budget_progress(
|
||||||
group_by: str = Query(default="group"),
|
group_by: str = Query(default="group"),
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export const api = {
|
|||||||
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
|
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
|
||||||
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
||||||
cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`),
|
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}` : ""}`),
|
cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||||||
cardIncomeExpense: () => request("/dashboard/income-expense"),
|
cardIncomeExpense: () => request("/dashboard/income-expense"),
|
||||||
cardBalances: () => request("/dashboard/balances"),
|
cardBalances: () => request("/dashboard/balances"),
|
||||||
|
|||||||
@@ -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 <Loading />;
|
||||||
|
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noExpenses")}</div>;
|
||||||
|
return (
|
||||||
|
<div className="dash-list">
|
||||||
|
{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 (
|
||||||
|
<div className="dash-progress" key={r.name}>
|
||||||
|
<div className="dash-progress-top share-row">
|
||||||
|
<span style={{ color: r.color }}>●</span>
|
||||||
|
<span className="grow ellipsis share-name">{r.name}</span>
|
||||||
|
<strong className="share-pct">{pct.toFixed(1).replace(".", ",")} %</strong>
|
||||||
|
<span className="muted small share-amount">{formatMoney(r.amount, data.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="budget-bar">
|
||||||
|
<div className="budget-bar-fill" style={{ width: `${pct}%`, background: r.color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="dash-total">
|
||||||
|
<span className="grow">{t("cards.total")}</span>
|
||||||
|
<strong>{formatMoney(data.total, data.currency)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Aufschlüsselungs-Option (Gruppen vs. Kategorien) – von Donut und Fortschritt geteilt.
|
// Aufschlüsselungs-Option (Gruppen vs. Kategorien) – von Donut und Fortschritt geteilt.
|
||||||
const AUFSCHLUESSELUNG = {
|
const AUFSCHLUESSELUNG = {
|
||||||
key: "group_by", labelKey: "cards.breakdown", def: "group",
|
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] },
|
"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 },
|
"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] },
|
"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 },
|
"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 },
|
"recent-transactions": { titleKey: "cards.recent", Comp: RecentTransactions, w: 4, h: 4, minW: 3, minH: 3 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1087,3 +1087,22 @@ tr.row-dup {
|
|||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
border-top: 1px solid var(--border);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"noBudget": "Noch kein Budget für diesen Monat.",
|
"noBudget": "Noch kein Budget für diesen Monat.",
|
||||||
"breakdown": "Aufschlüsselung",
|
"breakdown": "Aufschlüsselung",
|
||||||
"byGroup": "Gruppen",
|
"byGroup": "Gruppen",
|
||||||
"byCategory": "Kategorien"
|
"byCategory": "Kategorien",
|
||||||
|
"expenseShare": "Ausgaben-Anteile",
|
||||||
|
"noExpenses": "Noch keine Ausgaben in diesem Monat."
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server nicht erreichbar.",
|
"unreachable": "Server nicht erreichbar.",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"noBudget": "No budget for this month yet.",
|
"noBudget": "No budget for this month yet.",
|
||||||
"breakdown": "Breakdown",
|
"breakdown": "Breakdown",
|
||||||
"byGroup": "Groups",
|
"byGroup": "Groups",
|
||||||
"byCategory": "Categories"
|
"byCategory": "Categories",
|
||||||
|
"expenseShare": "Expense share",
|
||||||
|
"noExpenses": "No expenses this month yet."
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server unreachable.",
|
"unreachable": "Server unreachable.",
|
||||||
|
|||||||
Reference in New Issue
Block a user