From 3ef20034d31fde3c1c63d44a65439c27fe8bf623c34698778529661befcd9410 Mon Sep 17 00:00:00 2001 From: wlfb Date: Tue, 29 Sep 2026 16:45:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Aufteilung=20=E2=80=93=20Drill-down=20i?= =?UTF-8?q?n=20Kategorie=20per=20Klick=20auf=20Tortensegment=20(A13-T6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- finance/app/routers/gui.py | 75 ++++++++++- finance/app/static/style.css | 6 + .../app/templates/_aufteilung_kategorie.html | 9 ++ finance/app/templates/_aufteilung_torte.html | 2 +- finance/app/templates/_torte_macros.html | 19 ++- finance/app/templates/aufteilung.html | 2 + finance/tests/test_aufteilung_gui.py | 118 ++++++++++++++++++ 7 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 finance/app/templates/_aufteilung_kategorie.html 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 %} +
{{ detail_hinweis }}
+{% elif detail %} +

{{ detail.titel }}: {{ detail.summe|eur }} € / Monat

+{% if detail.enthaelt %}

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 %} -
+
{{ torte(haupt, haupt_titel) }}
diff --git a/finance/app/templates/_torte_macros.html b/finance/app/templates/_torte_macros.html index fedad93..06ef6cb 100644 --- a/finance/app/templates/_torte_macros.html +++ b/finance/app/templates/_torte_macros.html @@ -1,18 +1,29 @@ {# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts {s: engine.pie.Slice, q: Query-String für den Drill-down oder None, - gewaehlt: bool, zusatz: Zusatztext oder None}. #} + gewaehlt: bool, zusatz: Zusatztext oder None}. + Klickbar sind SVG-Segmente (hx-get direkt am , kein SVG-, weil + htmx nur HTML-Links am Navigieren hindert) und die Legenden-Links (auch + ohne JS und per Tastatur nutzbar). #} +{% macro seg_attrs(seg) -%} +class="seg {{ seg.s.color }}{% if seg.q %} klickbar{% endif %}{% if seg.gewaehlt %} gewaehlt{% endif %}" +{%- if seg.q %} hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}"{% endif %} +{%- endmacro %} + {% macro torte(segmente, titel) %}
{{ titel }}
{% for seg in segmente %}{% set s = seg.s %} - {% if s.path %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}) - {% else %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}){% endif %} + {% if s.path %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}) + {% else %}{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }}){% endif %} {% endfor %}
diff --git a/finance/app/templates/aufteilung.html b/finance/app/templates/aufteilung.html index 4ec874f..f105e9a 100644 --- a/finance/app/templates/aufteilung.html +++ b/finance/app/templates/aufteilung.html @@ -66,6 +66,8 @@

Gesamte Einkünfte: {{ s.income|eur }} €

{% include "_aufteilung_torte.html" %} +

Für Kategorie-Aufteilung auf Segment klicken

+
{% include "_aufteilung_kategorie.html" %}
{% endif %} {% endif %} diff --git a/finance/tests/test_aufteilung_gui.py b/finance/tests/test_aufteilung_gui.py index 4092035..aeb4a83 100644 --- a/finance/tests/test_aufteilung_gui.py +++ b/finance/tests/test_aufteilung_gui.py @@ -158,3 +158,121 @@ def test_tabelle_scrollbar_torte_darunter(client, db): t = _get(client, monat=MONAT).text assert re.search(r'
\s* t.index("") + + +def _drill(client, pfad="/aufteilung/kategorie", **params): + return client.get(pfad, params={"monat": MONAT, **params}) + + +def test_hinweistext_und_segment_links(client, db): + _login(client) + _daten(db) + t = _get(client, monat=MONAT).text + assert "Für Kategorie-Aufteilung auf Segment klicken" in t + assert 'hx-get="/aufteilung/kategorie?' in t + assert "kategorie=Wohnen" in t + assert 'hx-target="#aufteilung-kategorie"' in t + assert '
' in t + + +def test_frei_verfuegbar_ohne_link(client, db): + _login(client) + _daten(db) + t = _get(client, monat=MONAT).text + assert "kategorie=Frei" not in t + frei = re.search(r']*class="seg frei[^"]*"[^>]*>', t).group(0) + assert "hx-get" not in frei + + +def test_fragment_zweite_torte_und_oob(client, db): + _login(client) + _daten(db) + r = _drill(client, kategorie="Wohnen") + assert r.status_code == 200 + assert "Wohnen: 1.000,00 € / Monat" in r.text + assert "Miete" in r.text and "33,3 % der Einkünfte" in r.text + assert '
' in r.text + assert "gewaehlt" in r.text + assert "