diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index f805e4a..236b75f 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -171,7 +171,9 @@ def expense_share( geplant = sum(planned.get(i, 0) for i in ids) rows.append({"name": name, "color": color, "amount": val, "planned": geplant, - "over": geplant > 0 and val > geplant}) + "over": geplant > 0 and val > geplant, + # Womit sich die Zeile aufschluesseln laesst. + "ids": list(ids)}) rows.sort(key=lambda r: r["amount"], reverse=True) # Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben: @@ -194,6 +196,49 @@ def expense_share( } +@router.get("/expense-share/buchungen") +def expense_share_rows( + ids: list[int] = Query(default=[]), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + """Die einzelnen Buchungen hinter einem Anteil. + + Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Diese hier + kommt aus derselben Stelle wie die Summe auf der Karte – was hier steht, + ist genau das, was dort gezaehlt wurde, nicht eine zweite Suche mit + aehnlichen Bedingungen. + """ + base = base_currency(db, user) + label = _label(db, user) + start, end = budget.period_bounds(label, _start_day(db, user)) + gesucht = set(ids) + + konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} + zeilen = [] + for cat_id, txn, cents in budget.expense_rows(db, user, label, start, end, base): + if gesucht and cat_id not in gesucht: + continue + konto = konten.get(txn.account_id) + zeilen.append({ + "id": txn.id, + "date": txn.date.isoformat(), + "title": txn.payee or txn.description or "", + "account": konto.name if konto else "", + # Beides: Der Betrag, wie er auf dem Konto steht, UND der, mit dem + # gerechnet wurde. Weichen sie ab, liegt es an der Umrechnung – und + # das sieht man dann sofort. + "amount_cents": txn.amount_cents, + "currency": konto.currency if konto else base, + "base_cents": cents, + "type": txn.type.value, + "budget_period_manuell": txn.budget_period is not None, + }) + zeilen.sort(key=lambda z: (z["date"], z["id"])) + return {"rows": zeilen, "total": sum(z["base_cents"] for z in zeilen), + "currency": base, "von": start.isoformat(), "bis": end.isoformat()} + + @router.get("/budget-progress") def budget_progress( group_by: str = Query(default="group"), diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index afe1755..be889fc 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -218,18 +218,24 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]: } -def actual_expense_by_category(db: Session, user: User, label: str, start: date, end: date, - base: str) -> dict[int, int]: - """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" +def expense_rows(db: Session, user: User, label: str, start: date, end: date, + base: str) -> list[tuple[int, Transaction, int]]: + """Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag). + + Die Summe darueber IST der Ist-Wert – beides kommt aus dieser einen Stelle, + damit eine Aufstellung nie etwas anderes ergeben kann als die Zahl, die sie + erklaeren soll. + """ from .uebertraege import verknuepfte_ids - result: dict[int, int] = defaultdict(int) neutral = _neutrale_kategorien(db, user) # Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen # Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht # umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer # alten Kategorie sofort wieder mit. verknuepft = verknuepfte_ids(db, user) + zeilen: list[tuple[int, Transaction, int]] = [] + expenses = ( db.query(Transaction) .filter( @@ -243,7 +249,7 @@ def actual_expense_by_category(db: Session, user: User, label: str, start: date, for t in expenses: if t.category_id in neutral or t.id in verknuepft: continue - result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) + zeilen.append((t.category_id, t, _to_base(db, t.amount_eur_cents, base, t.date))) # Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als # budgetierte Ausgabe gegen budget_category_id. @@ -262,7 +268,16 @@ def actual_expense_by_category(db: Session, user: User, label: str, start: date, continue to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None if to_acc is not None and to_acc.type in SAVINGS_ACCOUNT_TYPES: - result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date) + zeilen.append((t.budget_category_id, t, _to_base(db, t.amount_eur_cents, base, t.date))) + return zeilen + + +def actual_expense_by_category(db: Session, user: User, label: str, start: date, end: date, + base: str) -> dict[int, int]: + """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" + result: dict[int, int] = defaultdict(int) + for cat_id, _txn, cents in expense_rows(db, user, label, start, end, base): + result[cat_id] += cents return result diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index 9abe731..f29a999 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -309,3 +309,63 @@ def test_der_eigene_monat_wird_nicht_festgeschrieben(admin): assert tx["budget_period"] == "2026-10" assert b.period_of(date(2026, 9, 26), 25) == "2026-10" admin.put("/api/settings/budget_start_day", params={"value": "1"}) + + +def test_aufstellung_ergibt_genau_die_summe_der_karte(admin): + """Die Aufstellung kommt aus derselben Stelle wie die Summe – sie kann also + gar nicht etwas anderes zeigen als das, was gezaehlt wurde.""" + import secrets + + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + acc = admin.post("/api/accounts", json={ + "name": f"Aufstellung {secrets.token_hex(3)}", "currency": "EUR"}).json() + leb = _lebensmittel(admin) + betraege = [1234, 5678, 999] + for c in betraege: + admin.post("/api/transactions", json={ + "type": "expense", "date": str(today), "account_id": acc["id"], + "amount_cents": c, "category_id": leb, "payee": f"Laden {c}"}) + + karte = admin.get("/api/dashboard/expense-share?group_by=category").json() + zeile = next(r for r in karte["rows"] if r["name"] == "Lebensmittel") + assert zeile["ids"], "ohne die Kategorien laesst sich nichts aufschluesseln" + + ids = "&".join(f"ids={i}" for i in zeile["ids"]) + auf = admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json() + assert auf["total"] == zeile["amount"], "Aufstellung und Karte muessen dasselbe ergeben" + assert sum(r["base_cents"] for r in auf["rows"]) == zeile["amount"] + assert auf["von"] == karte["von"] and auf["bis"] == karte["bis"] + + # Unsere drei Buchungen stehen darin, jede genau einmal. + unsere = [r for r in auf["rows"] if r["title"].startswith("Laden ")] + assert sorted(r["amount_cents"] for r in unsere) == sorted(betraege) + + +def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin): + """Wandert eine Buchung, wandert sie auch in der Aufstellung mit.""" + import secrets + + from app.services import budget as b + + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + hier = b.period_of(today, 1) + acc = admin.post("/api/accounts", json={ + "name": f"Wander {secrets.token_hex(3)}", "currency": "EUR"}).json() + leb = _lebensmittel(admin) + tx = admin.post("/api/transactions", json={ + "type": "expense", "date": str(today), "account_id": acc["id"], + "amount_cents": 4242, "category_id": leb, "payee": "Wanderladen"}).json() + + karte = admin.get("/api/dashboard/expense-share?group_by=category").json() + ids = "&".join(f"ids={i}" for i in + next(r for r in karte["rows"] if r["name"] == "Lebensmittel")["ids"]) + assert any(r["id"] == tx["id"] for r in + admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json()["rows"]) + + admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": b.shift_label(hier, 1)}) + auf = admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json() + assert not any(r["id"] == tx["id"] for r in auf["rows"]) + karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json() + assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"] diff --git a/web/src/api.js b/web/src/api.js index 6484465..9eab73b 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -329,6 +329,9 @@ export const api = { 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}` : ""}`), + // Woraus ein Anteil besteht – dieselbe Stelle, die auch die Summe bildet. + cardExpenseShareRows: (ids) => + request(`/dashboard/expense-share/buchungen?${ids.map((i) => `ids=${i}`).join("&")}`), cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`), cardIncomeExpense: () => request("/dashboard/income-expense"), cardBalances: () => request("/dashboard/balances"), diff --git a/web/src/components/Modal.jsx b/web/src/components/Modal.jsx index fe21e47..cc9e291 100644 --- a/web/src/components/Modal.jsx +++ b/web/src/components/Modal.jsx @@ -1,4 +1,5 @@ import { useRef } from "react"; +import { createPortal } from "react-dom"; import Icon from "./Icon"; @@ -9,7 +10,11 @@ export default function Modal({ title, onClose, children, footer, wide = false } // liegt das Mouseup auf dem Hintergrund; der Klick feuert dann auf dem // gemeinsamen Elternelement und schloss den Dialog mitten im Markieren. const startAussen = useRef(false); - return ( + // An den Seitenkoerper, nicht an die Stelle im Baum: Steht der Aufrufer in + // einem Element mit `transform` – jede Kachel der Uebersicht tut das –, waere + // das sonst der Bezugsrahmen fuer `position: fixed`, und der Dialog sitzt + // eingeklemmt in der Kachel statt ueber der Seite. + return createPortal(
{ startAussen.current = e.target === e.currentTarget; }} @@ -28,6 +33,7 @@ export default function Modal({ title, onClose, children, footer, wide = false }
{children}
{footer &&
{footer}
}
- + , + document.body, ); } diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 70bb88d..a05cd4c 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useSettings } from "../settings"; import IconChip from "../components/IconChip"; +import Modal from "../components/Modal"; +import Icon from "../components/Icon"; import Money from "../components/Money"; import { formatDate, formatMoney, formatAmount } from "../format"; import { tokenFarbe } from "./palette"; @@ -150,10 +152,71 @@ function RecentTransactions() { ); } +/** Woraus ein Anteil besteht. + * + * Eine Zahl, die man nicht aufschlüsseln kann, muss man glauben. Die Liste + * kommt aus derselben Stelle wie die Summe auf der Karte – sie kann also gar + * nicht etwas anderes zeigen als das, was gezählt wurde. */ +function AnteilDetails({ einheit, currency, onClose }) { + const { t } = useTranslation(); + const { formatDate } = useSettings(); + const [data, setData] = useState(null); + useEffect(() => { + let ok = true; + api.cardExpenseShareRows(einheit.ids || []) + .then((d) => ok && setData(d)) + .catch(() => ok && setData({ rows: [], __error: true })); + return () => { ok = false; }; + }, [einheit]); + + return ( + + {!data ? : ( + <> +

{formatDate(data.von)} – {formatDate(data.bis)}

+ {!data.rows.length &&
{t("cards.noExpenses")}
} +
+ {data.rows.map((r) => ( +
+ {formatDate(r.date)} + + {r.title || "–"} + {/* Steht eine Buchung hier, obwohl ihr Datum ausserhalb + liegt, ist das der Grund – und dann soll man ihn sehen. */} + {r.budget_period_manuell && ( + + + + )} + + {r.account} + + {formatMoney(r.amount_cents, r.currency)} + {/* Der umgerechnete Betrag nur, wenn er ein anderer ist – + sonst steht dieselbe Zahl zweimal nebeneinander. */} + {(r.currency !== currency || r.amount_cents !== r.base_cents) && ( + ≈ {formatMoney(r.base_cents, currency)} + )} + +
+ ))} +
+
+ {t("cards.total")} + {formatMoney(data.total, currency)} +
+ + )} +
+ ); +} + function ExpenseShare({ props }) { const { t } = useTranslation(); const groupBy = props?.group_by || "category"; const data = useData(() => api.cardExpenseShare(groupBy)); + const [details, setDetails] = useState(null); if (!data) return ; if (!data.rows?.length) return
{t("cards.noExpenses")}
; // Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am Ersten @@ -177,7 +240,13 @@ function ExpenseShare({ props }) { gesamtLabel={t("cards.spent")} gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null} fmt={(v) => formatMoney(v, data.currency)} + // Eine Zahl, die man nicht aufschlüsseln kann, muss man glauben. + onWaehlen={(b) => setDetails(data.rows.find((r) => r.name === b.name))} /> + {details && ( + setDetails(null)} /> + )} ); } @@ -189,7 +258,8 @@ function ExpenseShare({ props }) { // sonst nicht 100 %. const pct = data.total > 0 ? (r.amount / data.total) * 100 : 0; return ( -
+
+ ); })}
{t("cards.total")} {formatMoney(data.total, data.currency)}
+ {details && ( + setDetails(null)} /> + )} ); } diff --git a/web/src/dashboard/charts/Donut.jsx b/web/src/dashboard/charts/Donut.jsx index 3dd6099..05d742c 100644 --- a/web/src/dashboard/charts/Donut.jsx +++ b/web/src/dashboard/charts/Donut.jsx @@ -1,11 +1,14 @@ -import { useState } from "react"; +import { createElement, useState } from "react"; // Ringdiagramm für Anteile. segmente: [{ name, value, color, budget }] // `budget` ist "ok", "over" oder fehlt – fehlt es, wird nichts eingefärbt. // `gesamtBudget` gilt für die Mitte, solange kein Segment angezeigt wird. // fmt: Formatierer für die Werte (z. B. Geldbetrag). +// onWaehlen: Ist es gesetzt, lässt sich ein Anteil anklicken – dann kann der +// Aufrufer zeigen, woraus die Zahl besteht. export default function Donut({ segmente = [], gesamtLabel = "gesamt", - gesamtBudget = null, fmt = (v) => String(v) }) { + gesamtBudget = null, fmt = (v) => String(v), + onWaehlen = null }) { const [aktiv, setAktiv] = useState(null); const werte = segmente.filter((s) => s.value > 0); @@ -48,7 +51,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke} strokeDasharray={`${b.laenge} ${umfang - b.laenge}`} strokeDashoffset={-b.versatz} - onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} /> + onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} + onClick={onWaehlen ? () => onWaehlen(b) : undefined} + style={onWaehlen ? { cursor: "pointer" } : undefined} /> ))} {boegen.map((b) => (
  • setAktiv(b)} onMouseLeave={() => setAktiv(null)}> - - {b.name} - - {fmt(b.value)} · {Math.round(b.anteil * 100)} % - + {/* Anklickbar wird ein echter Knopf, kein Listeneintrag mit + onClick: Sonst ist der Anteil weder mit der Tastatur noch + mit einem Screenreader erreichbar. */} + {createElement(onWaehlen ? "button" : "div", + onWaehlen + ? { type: "button", className: "legend-zeile klickbar", + onClick: () => onWaehlen(b) } + : { className: "legend-zeile" }, + <> + + {b.name} + + {fmt(b.value)} · {Math.round(b.anteil * 100)} % + + )}
  • ))} diff --git a/web/src/finance.css b/web/src/finance.css index 99ad9fd..b11ed6a 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1523,3 +1523,67 @@ tr.row-dup { font-weight: 580; color: var(--text); } + +/* ---------- Woraus ein Anteil besteht ---------- + Eine Zahl, die man nicht aufschlüsseln kann, muss man glauben. Deshalb ist + jede Zeile der Ausgaben-Anteile ein Knopf – sie sieht aus wie vorher, führt + aber zu den Buchungen dahinter. */ +.anteil-knopf { + display: block; + width: 100%; + background: none; + border: 0; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); +} +.anteil-knopf:hover .share-name { + text-decoration: underline; +} +.chart-legend li > .legend-zeile { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + min-width: 0; + background: none; + border: 0; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: left; +} +.chart-legend li > .legend-zeile.klickbar { + cursor: pointer; +} +.chart-legend li > .legend-zeile.klickbar:hover .legend-name { + text-decoration: underline; +} + +.anteil-liste { + display: flex; + flex-direction: column; +} +.anteil-zeile { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; +} +/* Feste Breite für Datum und Konto: Sonst rutscht der Betrag von Zeile zu + Zeile, und genau die Beträge will man hier untereinander vergleichen. */ +.anteil-datum { + flex: 0 0 auto; + min-width: 5.5em; +} +.anteil-konto { + flex: 0 1 auto; + max-width: 9em; +}