diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index 5503fcb..35be1e7 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -3,6 +3,7 @@ import re from datetime import date, timedelta from decimal import Decimal from typing import Callable +from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.templating import Jinja2Templates @@ -14,8 +15,8 @@ from app.config import get_settings from app.db import get_session from app.engine.loans import add_months from app.engine.recurrence import occurrences -from app.engine.breakdown import FREI, BreakdownRow, Filters, share -from app.engine.pie import pie_slices +from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share +from app.engine.pie import UEBRIGE, pie_slices from app.formats import de_label, eur, pct from app.models.tables import (Account, Category, PlannedItem, ProjectionResult, RecurringItem, @@ -453,12 +454,80 @@ def _aufteilung_context(session: Session, today: date, monat: str | None, return ctx +def _basis_query(ctx: dict) -> dict: + q = {"monat": ctx["monat_wert"], "szenario": ctx["szenario_id"] or "", "gesendet": "1"} + f = ctx["filters"] + for key, on in (("fixposten", f.recurring), ("einmal", f.planned), ("kredite", f.loans)): + if on: + q[key] = "1" + return q + + +def _drilldown(ctx: dict, kategorie: str | None, sammel: str | None) -> None: + """Links der Haupttorte + ggf. zweite Torte. Die Sammelgruppe „Übrige" + läuft über sammel=1, damit eine echte Kategorie „Übrige" eindeutig bleibt.""" + ctx["detail"] = None + ctx["detail_hinweis"] = None + if ctx["b"] is None: + return + sammel_gewaehlt = sammel == "1" + basis = _basis_query(ctx) + for seg in ctx["haupt"]: + s = seg["s"] + if s.color == "frei": + continue + extra = {"sammel": "1"} if s.merged else {"kategorie": s.label} + seg["q"] = urlencode({**basis, **extra}) + seg["gewaehlt"] = s.merged if sammel_gewaehlt else (kategorie == s.label and not s.merged) + if sammel_gewaehlt: + merged = next((seg["s"] for seg in ctx["haupt"] if seg["s"].merged), None) + titel = UEBRIGE + enthaelt = list(merged.members) if merged else [] + elif kategorie: + titel, enthaelt = kategorie, [] + else: + return + rows = category_rows(ctx["b"].rows, set(enthaelt) if sammel_gewaehlt else {kategorie}) + if not rows: + ctx["detail_hinweis"] = f"Kategorie „{titel}“ hat in dieser Auswahl keine Kosten." + return + income = ctx["s"].income + segmente = _segmente(pie_slices([(r.name, -r.amount) for r in rows])) + for seg in segmente: + anteil = share(seg["s"].value, income) + seg["zusatz"] = f"{pct(anteil)} der Einkünfte" if anteil is not None else None + ctx["detail"] = {"titel": titel, "enthaelt": enthaelt, "segmente": segmente, + "summe": sum((-r.amount for r in rows), Decimal("0"))} + + @router.get("/aufteilung", dependencies=[Depends(gui_session)]) def aufteilung_page(request: Request, monat: str | None = None, szenario: str | None = None, fixposten: str | None = None, einmal: str | None = None, kredite: str | None = None, - gesendet: str | None = None, + gesendet: str | None = None, kategorie: str | None = None, + sammel: str | None = None, session: Session = Depends(get_session)): ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten, einmal, kredite, gesendet) + _drilldown(ctx, kategorie, sammel) return templates.TemplateResponse(request, "aufteilung.html", ctx) + + +@router.get("/aufteilung/kategorie", dependencies=[Depends(gui_session)]) +def aufteilung_kategorie_fragment(request: Request, monat: str | None = None, + szenario: str | None = None, + fixposten: str | None = None, + einmal: str | None = None, + kredite: str | None = None, + gesendet: str | None = None, + kategorie: str | None = None, + sammel: str | None = None, + session: Session = Depends(get_session)): + """htmx-Fragment: zweite Torte + Haupttorte out-of-band (Markierung).""" + ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten, + einmal, kredite, gesendet) + _drilldown(ctx, kategorie, sammel) + if ctx["error"]: + ctx["detail_hinweis"] = ctx["error"] + ctx["oob"] = ctx["b"] is not None + return templates.TemplateResponse(request, "_aufteilung_kategorie.html", ctx) diff --git a/finance/app/static/style.css b/finance/app/static/style.css index 8c06076..ab67b9a 100644 --- a/finance/app/static/style.css +++ b/finance/app/static/style.css @@ -273,3 +273,9 @@ label:has(input:disabled), label:has(select:disabled) { .swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; } .legende-wert { margin-left: auto; color: #555; white-space: nowrap; } .hinweis-klein { font-size: 0.85rem; color: #555; } + +.torte-svg .seg.klickbar { cursor: pointer; } +.torte-svg .seg.klickbar:hover { opacity: 0.85; } +.torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; } +.legende li.gewaehlt { font-weight: bold; } +#aufteilung-kategorie h2 { font-size: 1.1rem; margin-top: 1.5rem; } diff --git a/finance/app/templates/_aufteilung_kategorie.html b/finance/app/templates/_aufteilung_kategorie.html new file mode 100644 index 0000000..2d56817 --- /dev/null +++ b/finance/app/templates/_aufteilung_kategorie.html @@ -0,0 +1,9 @@ +{% from "_torte_macros.html" import torte %} +{% if detail_hinweis %} +
Enthält: {{ detail.enthaelt|join(', ') }}
{% endif %} +{{ torte(detail.segmente, 'Posten: ' ~ detail.titel) }} +{% endif %} +{% if oob %}{% include "_aufteilung_torte.html" %}{% endif %} diff --git a/finance/app/templates/_aufteilung_torte.html b/finance/app/templates/_aufteilung_torte.html index f0d3d40..de9a87b 100644 --- a/finance/app/templates/_aufteilung_torte.html +++ b/finance/app/templates/_aufteilung_torte.html @@ -1,4 +1,4 @@ {% from "_torte_macros.html" import torte %} -Gesamte Einkünfte: {{ s.income|eur }} €
Für Kategorie-Aufteilung auf Segment klicken
+