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 @@ +
| Datei | Bank | Zeitraum | Status | |
|---|---|---|---|---|
| {{ 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" %}
+
+
+
+ {% endif %}
+
+ Vorschau+Lädt…
+ |
+
| Noch keine Imports vorhanden. | ||||
| Datum | Verwendungszweck | Empfänger/Zahler | Betrag | |
|---|---|---|---|---|
| {{ 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 %}Der Kontoauszug-Import (Upload, Vorschau, Bestätigen) folgt in einem späteren Ausbauschritt.
+ +| Name | Betrag | Rhythmus | Fälligkeitstag | Kategorie | |
|---|---|---|---|---|---|
| {{ r.name }} | +{{ '%.2f'|format(r.amount) }} € | +{{ r.rhythm }} | +{{ r.due_day }} | +{{ category_names.get(r.category_id, '–') }} | ++ + | +
| Noch keine wiederkehrenden Posten. | |||||
| Name | Betrag | Fällig am | Kategorie | |
|---|---|---|---|---|
| {{ p.name }} | +{{ '%.2f'|format(p.amount) }} € | +{{ p.due.strftime('%d.%m.%Y') }} | +{{ category_names.get(p.category_id, '–') }} | ++ + | +
| Noch keine Einmalposten. | ||||
| Name | Summe | Zins p.a. | Laufzeit | Auszahlung | Tilgungsart | |
|---|---|---|---|---|---|---|
| {{ 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. | ||||||
Noch keine Szenarien.
+ {% endfor %} + + +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