feat: Zeigen, welche Buchungen in einem Ausgaben-Anteil stecken

Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Genau das
war der Streitpunkt: Die Karte nannte fuer Lebensmittel einen Betrag, und
es gab keine Moeglichkeit nachzusehen, welche Zahlungen darin einbezogen
sind und welche nicht.

Jetzt oeffnet ein Klick auf einen Anteil - in der Liste wie in der
Legende des Kreises - die Buchungen dahinter: Datum, Empfaenger, Konto,
Betrag. Die Liste kommt aus derselben Stelle, die auch die Summe bildet
(`budget.expense_rows`), sie kann also gar nicht etwas anderes zeigen als
das, was gezaehlt wurde. Ein Test haelt das fest.

Steht eine Buchung darin, obwohl ihr Datum ausserhalb des Zeitraums
liegt, traegt sie die Kalendermarke: Dann wurde ihr Budget-Monat von Hand
gesetzt, und das ist der Grund.

Nebenbei: Dialoge haengen jetzt am Seitenkoerper. Kacheln der Uebersicht
werden per `transform` positioniert - damit war die Kachel der
Bezugsrahmen fuer `position: fixed`, und der Dialog sass eingeklemmt
darin statt ueber der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 15:10:19 +02:00
co-authored by Claude Opus 5
parent 0b19e07ce0
commit ee36dae34a
8 changed files with 301 additions and 19 deletions
+46 -1
View File
@@ -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"),
+21 -6
View File
@@ -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
+60
View File
@@ -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"]
+3
View File
@@ -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"),
+8 -2
View File
@@ -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(
<div
className="modal-backdrop"
onMouseDown={(e) => { startAussen.current = e.target === e.currentTarget; }}
@@ -28,6 +33,7 @@ export default function Modal({ title, onClose, children, footer, wide = false }
<div className="modal-body">{children}</div>
{footer && <div className="btn-pair">{footer}</div>}
</div>
</div>
</div>,
document.body,
);
}
+76 -2
View File
@@ -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 (
<Modal title={einheit.name} onClose={onClose} wide>
{!data ? <Loading /> : (
<>
<p className="muted small">{formatDate(data.von)} – {formatDate(data.bis)}</p>
{!data.rows.length && <div className="dash-empty muted">{t("cards.noExpenses")}</div>}
<div className="anteil-liste">
{data.rows.map((r) => (
<div className="anteil-zeile" key={r.id}>
<span className="muted small anteil-datum">{formatDate(r.date)}</span>
<span className="grow ellipsis">
{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 && (
<span className="badge accent" style={{ marginLeft: 6 }}
title={t("transactions.budgetPeriodManual")}>
<Icon name="calendar" size={11} />
</span>
)}
</span>
<span className="muted small ellipsis anteil-konto">{r.account}</span>
<span className="amount small">
{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) && (
<span className="muted"> ≈ {formatMoney(r.base_cents, currency)}</span>
)}
</span>
</div>
))}
</div>
<div className="dash-total">
<span className="grow">{t("cards.total")}</span>
<strong>{formatMoney(data.total, currency)}</strong>
</div>
</>
)}
</Modal>
);
}
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 <Loading />;
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noExpenses")}</div>;
// 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 && (
<AnteilDetails einheit={details} currency={data.currency}
onClose={() => setDetails(null)} />
)}
</>
);
}
@@ -189,7 +258,8 @@ function ExpenseShare({ props }) {
// sonst nicht 100 %.
const pct = data.total > 0 ? (r.amount / data.total) * 100 : 0;
return (
<div className="dash-progress" key={r.name}>
<button type="button" className="dash-progress anteil-knopf" key={r.name}
onClick={() => setDetails(r)}>
<div className="dash-progress-top share-row">
<span style={{ color: r.color }}>●</span>
<span className="grow ellipsis share-name">{r.name}</span>
@@ -210,13 +280,17 @@ function ExpenseShare({ props }) {
style={{ width: `${pct}%`,
...(r.planned > 0 ? {} : { background: r.color }) }} />
</div>
</div>
</button>
);
})}
<div className="dash-total">
<span className="grow">{t("cards.total")}</span>
<strong>{formatMoney(data.total, data.currency)}</strong>
</div>
{details && (
<AnteilDetails einheit={details} currency={data.currency}
onClose={() => setDetails(null)} />
)}
</div>
);
}
+18 -3
View File
@@ -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} />
))}
</g>
<text x={mitte} y={mitte - 4} textAnchor="middle"
@@ -65,11 +70,21 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt",
<ul className="chart-legend">
{boegen.map((b) => (
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
{/* 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" },
<>
<span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span>
<span className={`legend-value${b.budget ? ` budget-${b.budget}` : ""}`}>
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
</span>
</>)}
</li>
))}
</ul>
+64
View File
@@ -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;
}