Compare commits

...

6 Commits

Author SHA256 Message Date
353ff6056c docs: Plan-Haekchen Ausbaustufe 7
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:21:11 +02:00
d6659d8b18 chore: Version 0.8.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:18:15 +02:00
4431640eb2 feat: Kategorien-Verwaltung auf der Admin-Seite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:48 +02:00
b21d7b9839 feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:06:32 +02:00
f03249bff1 feat: eigene Szenarien-Seite /szenarien
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:59:03 +02:00
7b273e218c docs: Spec + Plan Ausbaustufe 7 (Szenarien-Seite, Formular-UX, Kategorien)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:50:43 +02:00
14 changed files with 963 additions and 281 deletions

View File

@@ -0,0 +1,376 @@
# Ausbaustufe 7 Implementation Plan — Szenarien-Seite, Formular-UX, Kategorien (v0.8.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Szenarien auf eigene Seite `/szenarien`, verständliches Eintrags-Formular (Paar-Layout, Gesperrt-Kennzeichnung, dynamisches Wert-Label, abgesetzter Durchrechnen-Button), Kategorien-Verwaltung auf der Admin-Seite; Release v0.8.0.
**Architecture:** Kein Datenmodell-/Migrationsbedarf. Template-Umzug der Szenarien-Sektion aus `planning.html` in neues `szenarien.html` + Route in `gui.py`; CSS-Regeln in `style.css`; ein neuer Endpunkt `PATCH /api/categories/{id}`.
**Tech Stack:** FastAPI, Jinja2, htmx + json-form, CSS, pytest.
**Spec:** `docs/superpowers/specs/2026-07-20-szenarien-seite-ux-design.md`
## Global Constraints
- GUI deutsch, TT.MM.JJJJ, `|eur`; API-Werte englisch; Geldbeträge `Decimal`.
- UX-Regel: gesperrte Felder sichtbar + `disabled` + optisch gekennzeichnet; Edit-Zeilen per `hidden`.
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
- DATENSCHUTZ: keine echten Kontodaten in Commits/Tests/Doku.
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 181 passed, muss grün bleiben.
- Pfade relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
---
### Task 1: Szenarien-Seite `/szenarien`
**Files:**
- Create: `app/templates/szenarien.html`
- Modify: `app/templates/planning.html` (Szenarien-`<section>` Zeilen ~263-464 und szenario-spezifisches JS entfernen), `app/templates/base.html` (Nav + `toggleEdit`), `app/routers/gui.py` (neue Route, `planung_page` verschlanken), `app/templates/hilfe.html` (Ortsangaben)
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: bestehenden Szenario-Kontext (`_scenario_rows`, `modifier_kinds`), `gui_session`, alle bestehenden Templates/JS-Helfer.
- Produces: `GET /szenarien` (login-geschützt) mit Kontext `scenario_rows`, `loans`, `categories`, `category_names`, `recurring`, `recurring_names`, `modifier_kinds`; `toggleEdit` global in `base.html`. Task 2 baut auf `szenarien.html` auf.
- [x] **Step 1: Failing GUI-Tests** — in `tests/test_gui.py`:
```python
def test_szenarien_seite_und_nav(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="Seiten-Test", description="d"))
db.commit()
r = client.get("/szenarien")
assert r.status_code == 200
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
assert 'href="/szenarien"' in r.text # Nav-Punkt
# Planung enthaelt keine Szenarien-Sektion mehr:
p = client.get("/planung").text
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
assert "Wiederkehrende Posten" in p and "Kredite" in p
def test_szenarien_seite_braucht_login(client):
r = client.get("/szenarien", follow_redirects=False)
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
```
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → neue Tests FAIL (404)
- [x] **Step 2: Route**`app/routers/gui.py`: in `planung_page` die Zeilen `scenarios = _list_scenarios(...)`, `"scenario_rows": ...` und `"modifier_kinds": ...` entfernen (die übrigen Kontexteinträge bleiben). Neue Route direkt darunter:
```python
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "szenarien.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},
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})
```
- [x] **Step 3: Template-Umzug** — Neues `app/templates/szenarien.html`:
```html
{% extends "base.html" %}
{% block title %}Szenarien Finanzberatung{% endblock %}
{% block content %}
<h1>Szenarien</h1>
<section class="planning-section">
<!-- HIER: die komplette bisherige Szenarien-<section>-INNEREI aus
planning.html unverändert einfügen (alles zwischen
'<h2>Szenarien</h2>' … einschließlich des Fieldsets
'Neues Szenario anlegen'), ohne das umschließende <section>-Tag
doppelt zu setzen. -->
</section>
<script>
// Szenario-spezifische Helfer (aus planning.html hierher umgezogen):
// onModTargetTypeChange, onEntryArtChange, toggleScenarioLoan
// unverändert einfügen.
</script>
{% endblock %}
```
Der `<h2>Szenarien</h2>` entfällt (die Seite hat die `<h1>`-Überschrift). In `planning.html`: die gesamte Szenarien-`<section>` löschen; aus dem Script-Block `onModTargetTypeChange`, `onEntryArtChange`, `toggleScenarioLoan` und `toggleEdit` entfernen — `toggleEdit` zieht in den zentralen Script-Block von `base.html` (dort nach der json-form-Extension einfügen, unverändert inkl. Kommentar); `loadLoanSchedule`/`fmtEur`/`formatIsoDate` und `loadedSchedules` bleiben in `planning.html`.
- [x] **Step 4: Nav + Hilfe**`base.html`: `<a href="/szenarien">Szenarien</a>` zwischen Planung- und Admin-Link. `hilfe.html`: Formulierungen, die die Szenarien auf der Planung-Seite verorten, auf „Szenarien-Seite" anpassen (grep nach „Szenari" in `hilfe.html` und Ortsangaben prüfen).
- [x] **Step 5: Bestehende Tests umziehen**`tests/test_gui.py`: die Szenario-GUI-Tests (`test_szenario_gui_ende_und_einmalzahlungen`, `test_szenario_eintraege_tabelle`, Szenario-Anteile von `test_planung_hat_bearbeiten_formulare`) von `client.get("/planung")` auf `client.get("/szenarien")` umstellen. In `test_planung_hat_bearbeiten_formulare` den Scenario-Teil (Scenario-Seed + `hx-patch="/api/scenarios/`-Assertion) in einen neuen Test `test_szenarien_hat_bearbeiten_formular` auslagern, der `/szenarien` lädt; der Planung-Test behält rec/pln/loan mit `>= 3` Bearbeiten-Buttons. `test_pages_require_login`/`test_pages_render_after_login`: Pfad `/szenarien` in die jeweilige Pfadliste aufnehmen.
- [x] **Step 6: Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 7: Fable-Testagent-Abnahme** (Live-Approximation: beide Seiten rendern; keine Szenario-Reste auf /planung; `toggleEdit` genau EINMAL definiert [base.html], auf beiden Seiten funktionale Edit-Formulare; keine JS-Referenzen auf entfernte Funktionen in planning.html; Nav auf allen Seiten). Erst nach VERIFIED weiter.
- [x] **Step 8: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/ finance/tests/test_gui.py
git commit -m "feat: eigene Szenarien-Seite /szenarien"
```
---
### Task 2: Formular-UX — Paar-Layout, Gesperrt-Kennzeichnung, klare Struktur
**Files:**
- Modify: `app/static/style.css`, `app/templates/szenarien.html`, `tests/test_gui.py`
**Interfaces:**
- Consumes: `szenarien.html` aus Task 1 (Neuer-Eintrag-Formular mit Feldern kind/target_type/target_id/value/end_date/name/amount/due; `onEntryArtChange`).
- Produces: CSS-Klassen `.entry-form`, `.entry-art`, `.value-label`; dynamisches Wert-Label in `onEntryArtChange`; abgesetzter Durchrechnen-Block.
- [x] **Step 1: Failing GUI-Test**
```python
def test_neuer_eintrag_formular_struktur(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="UX-Test", description=""))
db.commit()
r = client.get("/szenarien").text
assert 'class="entry-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
assert '<hr' in r # Durchrechnen abgesetzt
```
Run: → FAIL
- [x] **Step 2: CSS**`app/static/style.css` ergänzen:
```css
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
.entry-form label,
.planning-section form label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 1.25rem 0.6rem 0;
white-space: nowrap;
}
.entry-form .entry-art {
display: block;
margin-bottom: 0.75rem;
}
.entry-form .entry-art > label { font-weight: bold; }
.entry-form .entry-art .muted { margin-left: 0.5rem; }
input:disabled, select:disabled {
background: #e3e3e3;
color: #8a8a8a;
cursor: not-allowed;
}
label:has(input:disabled), label:has(select:disabled) {
opacity: 0.55;
}
.project-block {
margin-top: 1rem;
}
```
(Falls `style.css` bereits eine kollidierende `label`-Regel für `.planning-section` hat: die neue Regel dahinter einsortieren, Spezifität prüfen.)
- [x] **Step 3: Formular-Markup** — in `szenarien.html` das Neuer-Eintrag-Formular umbauen:
- `<form … class="entry-form" …>` (Attribute inkl. `data-modifiers-url`/`data-planned-url` unverändert).
- Statt `<strong>Neuer Eintrag:</strong>`: erste Zeile
```html
<div class="entry-art">
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div>
```
- Wert-Label dynamisch: `<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0" title="Für diese Eintragsart nicht relevant"></label>` (Startzustand passend zur Default-Art `percent`).
- Die umschaltbaren Felder `end_date`, `name`, `amount`, `due` sowie die beiden Ziel-Selects bekommen `title="Für diese Eintragsart nicht relevant"`.
- Fieldset-Titel davor: `<h3>Einträge</h3>`-Tabelle bleibt; über dem Formular `<h4>Neuer Eintrag</h4>` (ersetzt das bisherige Inline-`<strong>`).
- Durchrechnen-Formular in einen abgesetzten Block:
```html
<hr>
<div class="project-block">
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
</div>
```
- [x] **Step 4: Dynamisches Wert-Label** — in `onEntryArtChange` (szenarien.html) nach der `setDisabled('value', …)`-Zeile ergänzen:
```javascript
var valueLabel = form.querySelector('.value-label');
if (valueLabel) {
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
}
```
- [x] **Step 5: Suite grün**`.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** — Live-Approximation + Handtrace: gerenderte Struktur (entry-art zuerst, Paare als `inline-flex`-Einheiten via CSS-Regeln vorhanden, Tooltips an allen Umschaltfeldern, `<hr>`+project-block), Wert-Label-Wechsel für alle 5 Arten, Startzustand konsistent (percent: Wert aktiv mit Label „Prozentsatz (%)"). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/static/style.css finance/app/templates/szenarien.html finance/tests/test_gui.py
git commit -m "feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung"
```
---
### Task 3: Kategorien-Verwaltung auf der Admin-Seite
**Files:**
- Modify: `app/routers/categories.py` (PATCH), `app/routers/admin.py` (Kontext), `app/templates/admin.html`
- Modify: `tests/test_crud_api.py`, `tests/test_admin.py`
**Interfaces:**
- Consumes: `CategoryIn`/`CategoryOut`, `POST /api/categories` (vorhanden); `toggleEdit` (seit Task 1 global in base.html); json-form.
- Produces: `PATCH /api/categories/{category_id}` (404 „Kategorie nicht gefunden", 409 „Kategorie existiert bereits"); Admin-Abschnitt „Kategorien" mit IDs `cat-row-{id}`/`cat-edit-{id}`.
- [x] **Step 1: Failing API-Test**`tests/test_crud_api.py`:
```python
def test_category_patch(client):
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
# Umbenennen
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200
# Kollision mit anderer Kategorie -> 409
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
assert r.status_code == 409
# unbekannte id -> 404
assert client.patch("/api/categories/99999", headers=H,
json={"name": "x"}).status_code == 404
```
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → FAIL (405)
- [x] **Step 2: PATCH-Endpunkt**`app/routers/categories.py`, nach `create_category`:
```python
@router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)):
cat = session.get(Category, category_id)
if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden")
clash = session.execute(
select(Category).where(Category.name == data.name)).scalar()
if clash is not None and clash.id != category_id:
raise HTTPException(409, "Kategorie existiert bereits")
cat.name = data.name
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
```
- [x] **Step 3: Failing GUI-Test**`tests/test_admin.py` (Muster für Login dort übernehmen; falls die Datei GUI-Zugriffe anders aufbaut, an bestehende Fixtures anlehnen):
```python
def test_admin_zeigt_kategorien_verwaltung(client, db):
from app.models.tables import Category
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Category(name="Admin-Kat"))
db.commit()
r = client.get("/admin").text
assert "Kategorien" in r and "Admin-Kat" in r
assert "Neue Kategorie anlegen" in r
assert 'hx-patch="/api/categories/' in r
assert 'hx-post="/api/categories"' in r
```
Run: → FAIL
- [x] **Step 4: Admin-Kontext + Template**`app/routers/admin.py`: in `admin_page` (und im Fehler-/Erfolgs-Re-Render von `admin_change_password`, damit der Abschnitt nie verschwindet — UX-Regel) `"categories": session.execute(select(Category)).scalars().all()` in den Template-Kontext aufnehmen (Imports `select`, `Category` ergänzen; `session`-Dependency, falls die Route noch keine hat). `admin.html`, neuer Abschnitt vor dem `<script>`:
```html
<section class="admin-section">
<h2>Kategorien</h2>
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
{% for c in categories %}
<tr id="cat-row-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
</tr>
<tr id="cat-edit-{{ c.id }}" hidden>
<td colspan="2">
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neue Kategorie anlegen</legend>
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required maxlength="100"></label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
```
- [x] **Step 5: Suite grün**`.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** (Live-Approximation: Anlegen → erscheint in Liste UND in den Kategorie-Dropdowns von Planung/Szenarien; Umbenennen-Roundtrip; 409 im Alert-Pfad; Admin-Fehler-Re-Render zeigt Abschnitt weiterhin). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/categories.py finance/app/routers/admin.py finance/app/templates/admin.html finance/tests/
git commit -m "feat: Kategorien-Verwaltung auf der Admin-Seite"
```
---
### Task 4: Release v0.8.0 — Redeploy, Live-Check, Ledger
**Files:** `finance/VERSION` (`0.7.0``0.8.0`), `.superpowers/sdd/progress.md`, Plan-Häkchen.
- [x] **Step 1: Suite final**`.venv/bin/python -m pytest -q` → alle grün.
- [x] **Step 2: Version + Commit**`echo "0.8.0" > finance/VERSION`, Commit `chore: Version 0.8.0`.
- [x] **Step 3: Redeploy**`./create_pod_finance.sh` (keine Migration). Service aktiv, Readiness 200.
- [x] **Step 4: Live-Smoke**`/api/version` == 0.8.0; Nav zeigt „Szenarien"; `/szenarien` zeigt Best Case mit Einträgen; `/planung` ohne Szenarien; `/admin` mit Kategorien-Abschnitt.
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: Wegwerf-Kategorie „SMOKE-A7" anlegen → umbenennen → in Dropdowns sichtbar (bleibt stehen, Hinweis im Bericht — Kategorien sind nicht löschbar; Namenswahl „zz-Smoke" damit sie unten einsortiert? Nein: Kategorie „SMOKE-A7" wird nach dem Test per direktem psql-DELETE entfernt, NUR wenn keine FK-Referenzen existieren — vorher COUNT-Checks auf transactions/category_rules/recurring_items/scenario_modifiers); Best-Case-Einträge unverändert; Formular-Struktur auf /szenarien (entry-art, Tooltips, hr). Erst nach VERIFIED weiter.
- [x] **Step 6: Ledger + Plan-Häkchen + Push.**
---
## Self-Review (beim Planschreiben)
- **Spec-Abdeckung:** Seite → Task 1; UX/CSS/Label/Durchrechnen → Task 2; Kategorien (PATCH + Admin-GUI) → Task 3; Release → Task 4. Vollständig.
- **Platzhalter:** Der Template-Umzug in Task 1 Step 3 ist bewusst als präziser Move beschrieben (Quelle: bestehende Szenarien-Sektion) statt als 200-Zeilen-Duplikat — die Quelle ist eindeutig benannt und unverändert zu übernehmen.
- **Typ-Konsistenz:** `toggleEdit` global (Task 1) wird von Task 3 (`cat-`-Präfix) vorausgesetzt; `entry-form`/`entry-art`/`value-label`-Klassen konsistent zwischen CSS (Task 2 Step 2) und Markup (Step 3) und Test (Step 1).

View File

@@ -0,0 +1,85 @@
# Design — Ausbaustufe 7: Szenarien-Seite, Formular-UX, Kategorien-Verwaltung (v0.8.0)
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Direktdurchlauf).
> Anlass: Nutzertest von v0.7.0 — vier Befunde: (1) Eintragsart
> „Einmalzahlung" im Formular nicht auffindbar (Layout verschleiert, welches
> Dropdown was steuert), (2) irrelevantes Kategorie-Dropdown bei
> Einmalzahlung + keine GUI zum Anlegen von Kategorien, (3) Label/Feld-Paare
> zerfließen über Zeilen, „Durchrechnen" klebt am Formular, (4) Felder
> „Wert" vs. „Betrag" unverständlich, gesperrte Felder nicht als gesperrt
> erkennbar.
## Nutzerentscheidungen
- Szenarien auf **eigene Seite** `/szenarien` (Nav-Punkt zwischen Planung
und Admin); Planung behält Fixposten/Einmalposten/Kredite.
- Nicht zur Eintragsart passende Felder bleiben **sichtbar, aber gesperrt
und optisch deutlich gekennzeichnet** (grau gefüllt, gedimmt) — keine
Ausblendung.
- Kategorien-Verwaltung auf der **Admin-Seite** (anlegen + umbenennen,
bewusst kein Löschen — FK-Abhängigkeiten zu Buchungen/Regeln/Posten/
Modifikatoren wären ein eigenes Thema).
- Kein „Keine"-Eintrag im Kategorie-Dropdown des Eintrags-Formulars
(Kategorie-Modifikator braucht zwingend ein Ziel).
## 1. Szenarien-Seite
- `routers/gui.py`: neue Route `GET /szenarien` (`szenarien_page`,
`gui_session`-geschützt) mit dem Szenario-Kontext (scenario_rows, loans,
categories/category_names, recurring/recurring_names, modifier_kinds);
`planung_page` verliert scenario_rows/modifier_kinds.
- Neues Template `szenarien.html` (extends base): komplette bisherige
Szenarien-`<section>` aus `planning.html` (Kopf+Edit, Kredite zuordnen,
Einträge-Tabelle, Neuer Eintrag, Durchrechnen, Ergebnis, Neues Szenario)
plus die szenario-spezifischen JS-Helfer (`onModTargetTypeChange`,
`onEntryArtChange`, `toggleScenarioLoan`).
- `toggleEdit` (von Planung UND Szenarien gebraucht) zieht in den zentralen
Script-Block von `base.html`.
- Nav in `base.html`: „Szenarien" zwischen „Planung" und „Admin".
- `hilfe.html`: Verweise „auf der Planung-Seite" für Szenarien anpassen.
## 2. Formular-UX (style.css + Templates)
- **Paar-Layout:** Regel für Formular-Labels der Planungs-/Szenarien-
Formulare: `label` als `inline-flex`-Einheit (Label + Feld in einer
Zeile, `white-space: nowrap`, `gap`), Paare mit Außenabstand voneinander
getrennt. Gilt automatisch auch für die Bearbeiten-Formulare (gleiches
Markup).
- **Gesperrt-Kennzeichnung:** `input:disabled, select:disabled` → graue
Füllung, gedimmter Text, `cursor: not-allowed`; `label:has(:disabled)`
reduzierte Deckkraft + `title`-Tooltip im Markup der umschaltbaren Felder
(„Für diese Eintragsart nicht relevant").
- **Struktur „Neuer Eintrag":** Eintragsart als erste, eigene Zeile mit
fettem Label und Hinweistext („die passenden Felder werden aktiv");
danach die Feldpaare. Dynamisches Wert-Label in `onEntryArtChange`:
percent → „Prozentsatz (%)", absolute → „Kürzung (€)", sonst „Wert" —
Label-Text via `<span class="value-label">`.
- **„Durchrechnen"** in eigenem, per Abstand/`<hr>` abgesetztem Block.
## 3. Kategorien-Verwaltung (Admin)
- Neuer Endpunkt `PATCH /api/categories/{category_id}` in
`routers/categories.py`: Body `CategoryIn`, 404 „Kategorie nicht
gefunden", 409 „Kategorie existiert bereits" bei Namenskollision mit
anderer Kategorie, Antwort `CategoryOut`.
- `admin.html`: Abschnitt „Kategorien" — Tabelle (Name + Bearbeiten/
Inline-Umbenennen nach `toggleEdit`-Muster, IDs `cat-row-{id}`/
`cat-edit-{id}`) + Formular „Neue Kategorie anlegen" (`POST
/api/categories` via json-form). Kein Löschen.
- `routers/admin.py::admin_page` lädt die Kategorienliste in den Kontext.
## 4. Tests / Release
- GUI-Tests: `/szenarien` rendert Szenario-Inhalte, ist login-geschützt und
in der Nav; `/planung` enthält KEINE Szenarien-Sektion mehr (bestehende
Szenario-GUI-Tests auf `/szenarien` umziehen); Admin-Seite zeigt
Kategorien-Abschnitt.
- API-Tests: PATCH Kategorie (Erfolg, 404, 409-Kollision, Umbenennung auf
eigenen Namen erlaubt).
- CSS ist nicht automatisiert testbar → Fable-Gate prüft die gerenderte
Struktur (Klassen/Tooltips/Label-Spans) und macht den Live-Check.
- `VERSION` → 0.8.0, Redeploy, Live-Check (Szenarien-Seite mit Best Case,
Kategorien-Anlage+Umbenennung live mit Wegwerf-Kategorie).
**Außerhalb des Scopes:** Kategorie-Löschen/-Zusammenführen, Regel-Pflege-
GUI, Änderungen an Engine/Projektion/Datenmodell (keine Migration).

View File

@@ -1 +1 @@
0.7.0
0.8.0

View File

@@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends, Form, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.models.tables import Category
from app.routers.gui import gui_session, templates
from app.services.admin import apply_rules_retroactively, change_password
@@ -10,8 +12,12 @@ router = APIRouter()
@router.get("/admin", dependencies=[Depends(gui_session)])
def admin_page(request: Request):
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None})
def admin_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "admin.html", {
"error": None,
"success": None,
"categories": session.execute(select(Category)).scalars().all(),
})
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
@@ -20,18 +26,24 @@ def admin_change_password(
alt: str = Form(...),
neu: str = Form(...),
neu2: str = Form(...),
session: Session = Depends(get_session),
):
categories = session.execute(select(Category)).scalars().all()
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
# Der Kategorien-Abschnitt bleibt aus demselben Grund in JEDEM Render-Pfad
# (Fehler wie Erfolg) im Kontext, statt nur bei der GET-Route.
if len(neu) < 8:
return templates.TemplateResponse(request, "admin.html", {
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
"success": None,
"categories": categories,
}, status_code=400)
if neu != neu2:
return templates.TemplateResponse(request, "admin.html", {
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
"success": None,
"categories": categories,
}, status_code=400)
try:
change_password(alt, neu)
@@ -39,11 +51,13 @@ def admin_change_password(
return templates.TemplateResponse(request, "admin.html", {
"error": str(exc),
"success": None,
"categories": categories,
}, status_code=400)
return templates.TemplateResponse(request, "admin.html", {
"error": None,
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
"Bestehende Sitzungen bleiben angemeldet.",
"categories": categories,
})

View File

@@ -48,6 +48,22 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
return CategoryOut.model_validate(cat)
@router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)):
cat = session.get(Category, category_id)
if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden")
clash = session.execute(
select(Category).where(Category.name == data.name)).scalar()
if clash is not None and clash.id != category_id:
raise HTTPException(409, "Kategorie existiert bereits")
cat.name = data.name
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
@router.get("/category-rules", response_model=list[CategoryRuleOut])
def list_category_rules(session: Session = Depends(get_session)):
rules = session.execute(

View File

@@ -291,7 +291,6 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
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},
@@ -300,8 +299,23 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"suggestions": _recurring_suggestions(session=session),
"planned": planned,
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
})
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "szenarien.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},
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})

View File

@@ -179,3 +179,34 @@ section.planning-section fieldset {
text-align: center;
font-size: 0.75rem;
}
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
.entry-form label,
.planning-section form label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 1.25rem 0.6rem 0;
white-space: nowrap;
}
.entry-form select,
.planning-section form select {
max-width: 14rem;
}
.entry-form .entry-art {
display: block;
margin-bottom: 0.75rem;
}
.entry-form .entry-art > label { font-weight: bold; }
.entry-form .entry-art .muted { margin-left: 0.5rem; }
input:disabled, select:disabled {
background: #e3e3e3;
color: #8a8a8a;
cursor: not-allowed;
}
label:has(input:disabled), label:has(select:disabled) {
opacity: 0.55;
}
.project-block {
margin-top: 1rem;
}

View File

@@ -32,6 +32,43 @@
<p id="apply-rules-result"></p>
</section>
<section class="admin-section">
<h2>Kategorien</h2>
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
{% for c in categories %}
<tr id="cat-row-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
</tr>
<tr id="cat-edit-{{ c.id }}" hidden>
<td colspan="2">
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neue Kategorie anlegen</legend>
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required maxlength="100"></label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<script>
function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result');

View File

@@ -14,6 +14,7 @@
<a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a>
<a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
@@ -74,6 +75,12 @@
return JSON.stringify(out);
}
});
function toggleEdit(prefix, id, editing) {
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
</script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body>

View File

@@ -260,278 +260,11 @@
</fieldset>
</section>
<section class="planning-section">
<h2>Szenarien</h2>
{% for row in scenario_rows %}
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p id="sc-row-{{ sc.id }}">
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
</p>
<div id="sc-edit-{{ sc.id }}" hidden>
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
</form>
</div>
<details>
<summary>Kredite zuordnen</summary>
{% for l in loans %}
<label class="inline-form">
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
{{ l.name }}
</label>
{% else %}
<p>Keine Kredite vorhanden.</p>
{% endfor %}
</details>
<h3>Einträge</h3>
<table>
<thead>
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr id="mod-row-{{ m.id }}">
<td>
{% 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 %}
</td>
<td>{{ m.kind|de_label }}</td>
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}{% endif %}</td>
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}{% else %}{% endif %}</td>
<td>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="mod-edit-{{ m.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
<label>Kategorie
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
<label>Posten
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Art
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% for sp in row.planned_items %}
<tr id="spi-row-{{ sp.id }}">
<td>{{ sp.name }}</td>
<td>Einmalzahlung</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="spi-edit-{{ sp.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% if not row.modifiers and not row.planned_items %}
<tr><td colspan="5">Keine Einträge.</td></tr>
{% endif %}
</tbody>
</table>
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<strong>Neuer Eintrag:</strong>
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category">
<label>Kategorie
<select name="target_id" data-type="int">
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" style="display:none">
<label>Posten
<select name="target_id" data-type="int" disabled>
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled></label>
<label>Name <input type="text" name="name" disabled></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled></label>
<label>Fällig am <input type="date" name="due" disabled></label>
<button type="submit">Hinzufügen</button>
</form>
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
{% if row.result %}
<p>
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
</p>
{% else %}
<p>Noch nicht durchgerechnet.</p>
{% endif %}
</fieldset>
{% else %}
<p>Noch keine Szenarien.</p>
{% endfor %}
<fieldset>
<legend>Neues Szenario anlegen</legend>
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Beschreibung <input type="text" name="description"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
<button type="submit">Szenario anlegen</button>
</form>
</fieldset>
</section>
<script>
// json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
function toggleEdit(prefix, id, editing) {
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
var recSpan = form.querySelector('.mod-target-recurring');
var catSelect = catSpan.querySelector('select');
var recSelect = recSpan.querySelector('select');
var isCategory = select.value === 'category';
catSpan.style.display = isCategory ? '' : 'none';
recSpan.style.display = isCategory ? 'none' : '';
catSelect.disabled = !isCategory;
recSelect.disabled = isCategory;
}
function onEntryArtChange(select) {
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
// kein name/amount/due), wird es uebersprungen.
var form = select.closest('form');
var art = select.value;
var isMod = art !== 'einmal';
function setDisabled(name, disabled) {
var el = form.querySelector('[name="' + name + '"]');
if (el) { el.disabled = disabled; }
}
setDisabled('value', !(art === 'percent' || art === 'absolute'));
setDisabled('end_date', art !== 'ende');
setDisabled('name', isMod);
setDisabled('amount', isMod);
setDisabled('due', isMod);
var typeSelect = form.querySelector('[name="target_type"]');
if (typeSelect) {
typeSelect.disabled = !isMod;
var isCategory = typeSelect.value === 'category';
var catSelect = form.querySelector('.mod-target-category select');
var recSelect = form.querySelector('.mod-target-recurring select');
if (catSelect) { catSelect.disabled = !isMod || !isCategory; }
if (recSelect) { recSelect.disabled = !isMod || isCategory; }
}
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
if (form.hasAttribute('data-planned-url')) {
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
: form.getAttribute('data-planned-url'));
htmx.process(form);
}
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
window.location.reload();
})
.catch(function (err) {
checkbox.checked = !checked;
alert('Zuordnung fehlgeschlagen: ' + err.message);
});
}
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
// mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
var loadedSchedules = {};
function formatIsoDate(iso) {

View File

@@ -0,0 +1,298 @@
{% extends "base.html" %}
{% block title %}Szenarien Finanzberatung{% endblock %}
{% block content %}
<h1>Szenarien</h1>
<section class="planning-section">
{% for row in scenario_rows %}
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p id="sc-row-{{ sc.id }}">
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
</p>
<div id="sc-edit-{{ sc.id }}" hidden>
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
</form>
</div>
<details>
<summary>Kredite zuordnen</summary>
{% for l in loans %}
<label class="inline-form">
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
{{ l.name }}
</label>
{% else %}
<p>Keine Kredite vorhanden.</p>
{% endfor %}
</details>
<h3>Einträge</h3>
<table>
<thead>
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr id="mod-row-{{ m.id }}">
<td>
{% 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 %}
</td>
<td>{{ m.kind|de_label }}</td>
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}{% endif %}</td>
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}{% else %}{% endif %}</td>
<td>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="mod-edit-{{ m.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
<label>Kategorie
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
<label>Posten
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Art
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% for sp in row.planned_items %}
<tr id="spi-row-{{ sp.id }}">
<td>{{ sp.name }}</td>
<td>Einmalzahlung</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="spi-edit-{{ sp.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% if not row.modifiers and not row.planned_items %}
<tr><td colspan="5">Keine Einträge.</td></tr>
{% endif %}
</tbody>
</table>
<h4>Neuer Eintrag</h4>
<form class="entry-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<div class="entry-art">
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div>
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category">
<label>Kategorie
<select name="target_id" data-type="int">
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" style="display:none">
<label>Posten
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant">
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label>
<button type="submit">Hinzufügen</button>
</form>
<hr>
<div class="project-block">
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
</div>
{% if row.result %}
<p>
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
</p>
{% else %}
<p>Noch nicht durchgerechnet.</p>
{% endif %}
</fieldset>
{% else %}
<p>Noch keine Szenarien.</p>
{% endfor %}
<fieldset>
<legend>Neues Szenario anlegen</legend>
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Beschreibung <input type="text" name="description"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
<button type="submit">Szenario anlegen</button>
</form>
</fieldset>
</section>
<script>
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
var recSpan = form.querySelector('.mod-target-recurring');
var catSelect = catSpan.querySelector('select');
var recSelect = recSpan.querySelector('select');
var isCategory = select.value === 'category';
catSpan.style.display = isCategory ? '' : 'none';
recSpan.style.display = isCategory ? 'none' : '';
catSelect.disabled = !isCategory;
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { catSelect.removeAttribute('title'); }
recSelect.disabled = isCategory;
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { recSelect.removeAttribute('title'); }
}
function onEntryArtChange(select) {
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
// kein name/amount/due), wird es uebersprungen.
var form = select.closest('form');
var art = select.value;
var isMod = art !== 'einmal';
function setDisabled(name, disabled) {
var el = form.querySelector('[name="' + name + '"]');
if (el) {
el.disabled = disabled;
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
else { el.removeAttribute('title'); }
}
}
setDisabled('value', !(art === 'percent' || art === 'absolute'));
var valueLabel = form.querySelector('.value-label');
if (valueLabel) {
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
}
setDisabled('end_date', art !== 'ende');
setDisabled('name', isMod);
setDisabled('amount', isMod);
setDisabled('due', isMod);
var typeSelect = form.querySelector('[name="target_type"]');
if (typeSelect) {
typeSelect.disabled = !isMod;
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { typeSelect.removeAttribute('title'); }
var isCategory = typeSelect.value === 'category';
var catSelect = form.querySelector('.mod-target-category select');
var recSelect = form.querySelector('.mod-target-recurring select');
if (catSelect) {
catSelect.disabled = !isMod || !isCategory;
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { catSelect.removeAttribute('title'); }
}
if (recSelect) {
recSelect.disabled = !isMod || isCategory;
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { recSelect.removeAttribute('title'); }
}
}
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
if (form.hasAttribute('data-planned-url')) {
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
: form.getAttribute('data-planned-url'));
htmx.process(form);
}
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
window.location.reload();
})
.catch(function (err) {
checkbox.checked = !checked;
alert('Zuordnung fehlgeschlagen: ' + err.message);
});
}
</script>
{% endblock %}

View File

@@ -234,6 +234,21 @@ def test_admin_passwort_success_returns_200_with_confirmation(
assert r2.status_code == 200
# --- Kategorien-Verwaltung --------------------------------------------------
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
from app.models.tables import Category
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
db.add(Category(name="Admin-Kat"))
db.commit()
r = client.get("/admin").text
assert "Kategorien" in r and "Admin-Kat" in r
assert "Neue Kategorie anlegen" in r
assert 'hx-patch="/api/categories/' in r
assert 'hx-post="/api/categories"' in r
# --- Regeln neu anwenden ---------------------------------------------------
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):

View File

@@ -113,6 +113,23 @@ def test_rules_categorize(client):
assert txs[0]["category_id"] == cat["id"]
def test_category_patch(client):
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
# Umbenennen
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200
# Kollision mit anderer Kategorie -> 409
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
assert r.status_code == 409
# unbekannte id -> 404
assert client.patch("/api/categories/99999", headers=H,
json={"name": "x"}).status_code == 404
def test_patch_account_name(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()

View File

@@ -9,7 +9,7 @@ H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
@@ -17,7 +17,7 @@ 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", "/import", "/salden", "/planung", "/hilfe"):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
r = client.get(path)
assert r.status_code == 200
assert "Finanzberatung" in r.text
@@ -317,15 +317,23 @@ def test_planung_hat_bearbeiten_formulare(client, db):
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/planung").text
assert r.count(">Bearbeiten</button>") >= 4
assert r.count(">Bearbeiten</button>") >= 3
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'):
'hx-patch="/api/loans/'):
assert fragment in r, fragment
def test_szenarien_hat_bearbeiten_formular(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/szenarien").text
assert r.count(">Bearbeiten</button>") >= 1
assert 'hx-patch="/api/scenarios/' in r
def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
@@ -350,7 +358,7 @@ def test_szenario_gui_ende_und_einmalzahlungen(client, db):
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/planung").text
r = client.get("/szenarien").text
assert 'value="ende"' in r # neue Art im Dropdown
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
@@ -372,7 +380,7 @@ def test_szenario_eintraege_tabelle(client, db):
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/planung").text
r = client.get("/szenarien").text
assert "Einträge" in r
# alte details-Abschnitte sind ersetzt
assert "<summary>Modifikatoren</summary>" not in r
@@ -387,3 +395,34 @@ def test_szenario_eintraege_tabelle(client, db):
assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r
def test_szenarien_seite_und_nav(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="Seiten-Test", description="d"))
db.commit()
r = client.get("/szenarien")
assert r.status_code == 200
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
assert 'href="/szenarien"' in r.text # Nav-Punkt
# Planung enthaelt keine Szenarien-Sektion mehr:
p = client.get("/planung").text
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
assert "Wiederkehrende Posten" in p and "Kredite" in p
def test_szenarien_seite_braucht_login(client):
r = client.get("/szenarien", follow_redirects=False)
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
def test_neuer_eintrag_formular_struktur(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="UX-Test", description=""))
db.commit()
r = client.get("/szenarien").text
assert 'class="entry-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
assert '<hr' in r # Durchrechnen abgesetzt