From afbbcbe9a5c7bb5ee0d13490e1d0bc80c31756a8887fbc802616c131d521ed47 Mon Sep 17 00:00:00 2001 From: wlfb Date: Fri, 17 Jul 2026 19:05:58 +0200 Subject: [PATCH] feat: Import-Seite mit Drag-and-Drop und Planungs-Seite Ersetzt die Platzhalter-Routen aus Task 12 durch echte GUI-Seiten: /import mit Drop-Zone (Fetch-Upload), gepollter Importliste (Status-Badges, aufklappbarer Vorschau samt Saldo-/Duplikat-Anzeige, Uebernehmen/Verwerfen) und /planung mit den vier Abschnitten Fixposten (inkl. Vorschlaege), Einmalposten, Kredite (Tilgungsplan-Aufklapper) und Szenarien (Kredit-Zuordnung, Modifikatoren, Durchrechnen mit Ergebnisanzeige). Co-Authored-By: Claude Fable 5 --- finance/app/routers/gui.py | 80 ++++- finance/app/static/style.css | 50 +++ finance/app/templates/_import_list.html | 49 +++ finance/app/templates/_preview_table.html | 29 ++ finance/app/templates/import.html | 65 +++- finance/app/templates/planning.html | 387 ++++++++++++++++++++++ finance/app/templates/planung.html | 6 - finance/tests/test_gui.py | 30 +- 8 files changed, 681 insertions(+), 15 deletions(-) create mode 100644 finance/app/templates/_import_list.html create mode 100644 finance/app/templates/_preview_table.html create mode 100644 finance/app/templates/planning.html delete mode 100644 finance/app/templates/planung.html diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index f6e0451..ef26afc 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -10,7 +10,15 @@ from app.auth import COOKIE, session_valid from app.db import get_session from app.engine.recurrence import occurrences from app.models.tables import (Account, Category, PlannedItem, - ProjectionResult, RecurringItem) + ProjectionResult, RecurringItem, + ScenarioLoan, ScenarioModifier) +from app.routers.imports import list_imports as _list_imports +from app.routers.imports import preview as _preview_import +from app.routers.planning import list_loans as _list_loans +from app.routers.planning import list_planned as _list_planned +from app.routers.planning import list_recurring as _list_recurring +from app.routers.planning import recurring_suggestions as _recurring_suggestions +from app.routers.scenarios import list_scenarios as _list_scenarios from app.routers.transactions import list_transactions from app.services.balances import account_balance, total_balance @@ -102,12 +110,70 @@ def buchungen( @router.get("/import", dependencies=[Depends(gui_session)]) -def import_page(request: Request): - # Platzhalter, wird in Task 13 mit Upload-/Vorschau-Funktion gefüllt. - return templates.TemplateResponse(request, "import.html", {}) +def import_page(request: Request, session: Session = Depends(get_session)): + return templates.TemplateResponse(request, "import.html", { + "statements": _list_imports(session=session), + }) + + +@router.get("/import/list", dependencies=[Depends(gui_session)]) +def import_list_fragment(request: Request, session: Session = Depends(get_session)): + return templates.TemplateResponse(request, "_import_list.html", { + "statements": _list_imports(session=session), + }) + + +@router.get("/import/{statement_id}/preview-fragment", dependencies=[Depends(gui_session)]) +def import_preview_fragment(statement_id: int, request: Request, + session: Session = Depends(get_session)): + result = _preview_import(statement_id, session=session) + return templates.TemplateResponse(request, "_preview_table.html", { + "statement": result.statement, + "transactions": result.transactions, + "balance_ok": result.balance_ok, + "duplicates": result.duplicates, + }) + + +def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]: + """Baut je Szenario den Kontext fürs Template: zugeordnete Kredite, + Modifikatoren und (falls vorhanden) das letzte Projektionsergebnis.""" + rows = [] + for sc in scenarios: + assigned_loan_ids = { + sl.loan_id for sl in session.execute( + select(ScenarioLoan).where(ScenarioLoan.scenario_id == sc.id) + ).scalars() + } + modifiers = session.execute( + select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id) + ).scalars().all() + rows.append({ + "scenario": sc, + "assigned_loan_ids": assigned_loan_ids, + "modifiers": modifiers, + "result": session.get(ProjectionResult, sc.id), + }) + return rows @router.get("/planung", dependencies=[Depends(gui_session)]) -def planung_page(request: Request): - # Platzhalter, wird in Task 13 mit Fixposten-/Szenario-Verwaltung gefüllt. - return templates.TemplateResponse(request, "planung.html", {}) +def planung_page(request: Request, session: Session = Depends(get_session)): + categories = session.execute(select(Category)).scalars().all() + recurring = _list_recurring(session=session) + planned = _list_planned(session=session) + loans = _list_loans(session=session) + scenarios = _list_scenarios(session=session) + return templates.TemplateResponse(request, "planning.html", { + "categories": categories, + "category_names": {c.id: c.name for c in categories}, + "recurring": recurring, + "recurring_names": {r.id: r.name for r in recurring}, + "suggestions": _recurring_suggestions(session=session), + "planned": planned, + "loans": loans, + "scenario_rows": _scenario_rows(session, scenarios, loans), + "rhythms": ["monthly", "quarterly", "yearly"], + "repayment_types": ["annuity", "bullet"], + "modifier_kinds": ["percent", "absolute", "remove"], + }) diff --git a/finance/app/static/style.css b/finance/app/static/style.css index 232c509..1a0313b 100644 --- a/finance/app/static/style.css +++ b/finance/app/static/style.css @@ -92,3 +92,53 @@ iframe.grafana { button { cursor: pointer; } + +.badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.85rem; +} + +.badge-warning { + background: #fff3cd; + color: #7a5c00; +} + +.badge-error { + background: #fdecea; + color: #b00020; +} + +.badge-ok { + background: #e6f4ea; + color: #1e7e34; +} + +#dropzone { + border: 2px dashed #999; + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + margin-bottom: 0.5rem; + color: #555; +} + +#dropzone.dragover { + background: #f0f6ff; + border-color: #06c; +} + +.inline-form { + display: inline-block; + margin-right: 0.5rem; +} + +section.planning-section { + margin-bottom: 2rem; +} + +section.planning-section fieldset { + margin-top: 0.75rem; +} diff --git a/finance/app/templates/_import_list.html b/finance/app/templates/_import_list.html new file mode 100644 index 0000000..9b43ac3 --- /dev/null +++ b/finance/app/templates/_import_list.html @@ -0,0 +1,49 @@ + + + + + + {% for s in statements %} + + + + + + + + {% else %} + + {% endfor %} + +
DateiBankZeitraumStatus
{{ s.filename }}{{ s.bank }} + {% if s.period_start and s.period_end %} + {{ s.period_start.strftime('%d.%m.%Y') }} – {{ s.period_end.strftime('%d.%m.%Y') }} + {% endif %} + + {% if s.status == "draft" %} + wartet auf Bestätigung + {% elif s.status == "error" %} + Fehler: {{ s.error_message }} + {% elif s.status == "confirmed" %} + übernommen + {% else %} + {{ s.status }} + {% endif %} + + {% if s.status == "draft" %} +
+ Vorschau +
Lädt…
+
+
+ +
+ {% endif %} +
+ +
+
Noch keine Imports vorhanden.
diff --git a/finance/app/templates/_preview_table.html b/finance/app/templates/_preview_table.html new file mode 100644 index 0000000..74d699b --- /dev/null +++ b/finance/app/templates/_preview_table.html @@ -0,0 +1,29 @@ + + + + + + {% for tx in transactions %} + + + + + + + + {% else %} + + {% endfor %} + +
DatumVerwendungszweckEmpfänger/ZahlerBetrag
{{ tx.booking_date.strftime('%d.%m.%Y') }}{{ tx.purpose }}{{ tx.counterparty }}{{ '%.2f'|format(tx.amount) }} €{% if tx.is_duplicate %}Duplikat{% endif %}
Keine Buchungen in diesem Import.
+

+ Saldo-Status: + {% if balance_ok %} + Saldo stimmt + {% else %} + Saldo-Differenz – bitte prüfen + {% endif %} + · Eröffnungssaldo: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '–' }} € + · Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '–' }} € + · Duplikate: {{ duplicates }} +

diff --git a/finance/app/templates/import.html b/finance/app/templates/import.html index 7954419..4356d67 100644 --- a/finance/app/templates/import.html +++ b/finance/app/templates/import.html @@ -2,5 +2,68 @@ {% block title %}Import – Finanzberatung{% endblock %} {% block content %}

Import

-

Der Kontoauszug-Import (Upload, Vorschau, Bestätigen) folgt in einem späteren Ausbauschritt.

+ +
+ Kontoauszug-PDF hier ablegen oder klicken zum Hochladen + +
+

+ +

Importe

+
+ {% include "_import_list.html" %} +
+ + {% endblock %} diff --git a/finance/app/templates/planning.html b/finance/app/templates/planning.html new file mode 100644 index 0000000..e7a8870 --- /dev/null +++ b/finance/app/templates/planning.html @@ -0,0 +1,387 @@ +{% extends "base.html" %} +{% block title %}Planung – Finanzberatung{% endblock %} +{% block content %} +

Planung

+ +
+

Wiederkehrende Posten

+ + + + + + {% for r in recurring %} + + + + + + + + + {% else %} + + {% endfor %} + +
NameBetragRhythmusFälligkeitstagKategorie
{{ r.name }}{{ '%.2f'|format(r.amount) }} €{{ r.rhythm }}{{ r.due_day }}{{ category_names.get(r.category_id, '–') }} +
+ +
+
Noch keine wiederkehrenden Posten.
+ +
+ Neuen wiederkehrenden Posten anlegen +
+ + + + + + +
+
+ + {% if suggestions %} +
+ Vorschläge aus Buchungen + + + + + + {% for s in suggestions %} + + + + + + + + {% endfor %} + +
NameBetragRhythmusFälligkeitstag
{{ s.name }}{{ '%.2f'|format(s.amount) }} €{{ s.rhythm }}{{ s.due_day }} +
+ + + + + + +
+
+
+ {% endif %} +
+ +
+

Einmalposten

+ + + + + + {% for p in planned %} + + + + + + + + {% else %} + + {% endfor %} + +
NameBetragFällig amKategorie
{{ p.name }}{{ '%.2f'|format(p.amount) }} €{{ p.due.strftime('%d.%m.%Y') }}{{ category_names.get(p.category_id, '–') }} +
+ +
+
Noch keine Einmalposten.
+ +
+ Neuen Einmalposten anlegen +
+ + + + + +
+
+
+ +
+

Kredite

+ + + + + + {% for l in loans %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
NameSummeZins p.a.LaufzeitAuszahlungTilgungsart
{{ l.name }}{{ '%.2f'|format(l.principal) }} €{{ '%.2f'|format(l.annual_rate_pct) }} %{{ l.term_months }} Monate{{ l.payout_date.strftime('%d.%m.%Y') }}{{ l.repayment_type }} +
+ +
+
+
+ Tilgungsplan +
Wird beim Öffnen geladen…
+
+
Noch keine Kredite.
+ +
+ Neuen Kredit anlegen +
+ + + + + + + +
+
+
+ +
+

Szenarien

+ {% for row in scenario_rows %} + {% set sc = row.scenario %} +
+ {{ sc.name }} +

+ {{ sc.description }} + · Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }} + · Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }} +

+ +
+ Kredite zuordnen + {% for l in loans %} + + {% else %} +

Keine Kredite vorhanden.

+ {% endfor %} +
+ +
+ Modifikatoren + + + + + + {% for m in row.modifiers %} + + + + + + + {% else %} + + {% endfor %} + +
ZielArtWert
+ {% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }} + {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %} + {{ m.kind }}{{ '%.2f'|format(m.value) }} +
+ +
+
Keine Modifikatoren.
+ +
+ + + + + + + + +
+
+ +
+ +
+ + {% if row.result %} +

+ Tiefpunkt: {{ '%.2f'|format(row.result.low_point_balance) }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}
+ {% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}
{% endif %} + {% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}
{% endif %} + Kurven in Grafana ansehen. +

+ {% else %} +

Noch nicht durchgerechnet.

+ {% endif %} +
+ {% else %} +

Noch keine Szenarien.

+ {% endfor %} + +
+ Neues Szenario anlegen +
+ + + + + +
+
+
+ + +{% endblock %} diff --git a/finance/app/templates/planung.html b/finance/app/templates/planung.html deleted file mode 100644 index f85853a..0000000 --- a/finance/app/templates/planung.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "base.html" %} -{% block title %}Planung – Finanzberatung{% endblock %} -{% block content %} -

Planung

-

Die Verwaltung von Fixposten, Einmalposten und Szenarien folgt in einem späteren Ausbauschritt.

-{% endblock %} diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py index 125f668..a6354d1 100644 --- a/finance/tests/test_gui.py +++ b/finance/tests/test_gui.py @@ -7,7 +7,35 @@ def test_pages_require_login(client): def test_pages_render_after_login(client): client.post("/login", data={"username": "admin", "password": "geheim"}) - for path in ("/", "/buchungen"): + for path in ("/", "/buchungen", "/import", "/planung"): r = client.get(path) assert r.status_code == 200 assert "Finanzberatung" in r.text + + +def test_import_page_has_dropzone_and_list(client): + client.post("/login", data={"username": "admin", "password": "geheim"}) + r = client.get("/import") + assert r.status_code == 200 + assert 'id="dropzone"' in r.text + assert 'id="imports"' in r.text + + +def test_import_list_fragment_requires_login(client): + r = client.get("/import/list", follow_redirects=False) + assert r.status_code in (302, 303) + + +def test_import_list_fragment_renders_after_login(client): + client.post("/login", data={"username": "admin", "password": "geheim"}) + r = client.get("/import/list") + assert r.status_code == 200 + assert "Noch keine Imports" in r.text + + +def test_planning_page_has_all_sections(client): + client.post("/login", data={"username": "admin", "password": "geheim"}) + r = client.get("/planung") + assert r.status_code == 200 + for heading in ("Wiederkehrende Posten", "Einmalposten", "Kredite", "Szenarien"): + assert heading in r.text