Compare commits
6 Commits
2282aa3922
...
974ef5c208
| Author | SHA256 | Date | |
|---|---|---|---|
| 974ef5c208 | |||
| 421899b922 | |||
| cb44194daf | |||
| 4843f2e130 | |||
| 1708abeb91 | |||
| 39c6a65b1d |
449
docs/superpowers/plans/2026-07-20-ausbaustufe-6.md
Normal file
449
docs/superpowers/plans/2026-07-20-ausbaustufe-6.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Ausbaustufe 6 Implementation Plan — Szenario-Einträge-Tabelle (v0.7.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:** Pro Szenario eine immer sichtbare, editierbare Tabelle „Einträge" (Modifikatoren + szenario-eigene Einmalzahlungen gemeinsam) mit Inline-Bearbeiten und einem gemeinsamen Anlege-Formular; Release v0.7.0.
|
||||
|
||||
**Architecture:** Datenmodell unverändert — zwei neue Full-Body-PATCH-Endpunkte in `routers/scenarios.py`; Template-Umbau in `planning.html` ersetzt die zwei `<details>`-Abschnitte durch eine Tabelle; Feld-Umschaltung per `disabled` (JS `onEntryArtChange` ersetzt `onModKindChange`), Endpunkt-Wechsel des Anlege-Formulars per `hx-post`-Attribut + `htmx.process`.
|
||||
|
||||
**Tech Stack:** FastAPI + Pydantic v2, Jinja2, htmx + json-form-Extension, pytest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Geldbeträge `Decimal`; JSON-API Punkt-Dezimal; HTML deutsch (`|eur`, `|de_label`, TT.MM.JJJJ).
|
||||
- API-/DB-Werte englisch; neuer Formular-Artwert `einmal` ist NUR clientseitig (landet nie in der DB — an `/planned` gesendet wird er von Pydantic ignoriert).
|
||||
- UX-Regel: Umschalten/Verstecken nur per `disabled`/`hidden`, nie DOM-Entfernen.
|
||||
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
|
||||
- DATENSCHUTZ: keine echten Namen/Beträge in Commits/Tests/Doku; Live-Demo-Daten nur DB/Chat.
|
||||
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 178 passed, muss grün bleiben.
|
||||
- Pfade unten relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: PATCH-Endpunkte für Modifikatoren und Szenario-Einmalzahlungen
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/routers/scenarios.py`
|
||||
- Modify: `tests/test_crud_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ModifierIn`/`ModifierOut`, `ScenarioPlannedIn`/`ScenarioPlannedOut`, `_get_scenario` (alle vorhanden in `scenarios.py`).
|
||||
- Produces: `PATCH /api/scenarios/{scenario_id}/modifiers/{mod_id}` (Body `ModifierIn`, Antwort `ModifierOut`, 404 „Modifikator nicht gefunden") und `PATCH /api/scenarios/{scenario_id}/planned/{item_id}` (Body `ScenarioPlannedIn`, Antwort `ScenarioPlannedOut`, 404 „Einmalzahlung nicht gefunden"). Task 2 (GUI) sendet auf genau diese URLs.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — in `tests/test_crud_api.py` ergänzen:
|
||||
|
||||
```python
|
||||
def test_modifier_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
|
||||
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent",
|
||||
"value": "10.00"}).json()
|
||||
# Wert aendern
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
|
||||
assert r.status_code == 200 and r.json()["value"] == "25.00"
|
||||
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
|
||||
# ende ohne Datum -> 422
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||
assert r.status_code == 422
|
||||
# zurueck auf remove: end_date wird genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_scenario_planned_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
|
||||
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Zufluss geaendert"
|
||||
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 404
|
||||
```
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → neue Tests FAIL (405 Method Not Allowed)
|
||||
|
||||
- [x] **Step 2: Endpunkte implementieren** — `app/routers/scenarios.py`, direkt nach `delete_modifier` bzw. nach `delete_scenario_planned`:
|
||||
|
||||
```python
|
||||
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
|
||||
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = session.get(ScenarioModifier, mod_id)
|
||||
if modifier is None or modifier.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(modifier, key, value)
|
||||
session.commit()
|
||||
session.refresh(modifier)
|
||||
return ModifierOut.model_validate(modifier)
|
||||
```
|
||||
|
||||
```python
|
||||
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
|
||||
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update, Muster patch_modifier.
|
||||
_get_scenario(session, scenario_id)
|
||||
item = session.get(ScenarioPlannedItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioPlannedOut.model_validate(item)
|
||||
```
|
||||
|
||||
- [x] **Step 3: Tests grün + Suite**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q && .venv/bin/python -m pytest -q`
|
||||
Expected: alles PASS (178 + 2)
|
||||
|
||||
- [x] **Step 4: Fable-Testagent-Abnahme** (Faktencheck: Validator-Wirkung beim PATCH [ende↔andere Arten], 404-Pfade, keine Nebenwirkungen auf Projektion). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/routers/scenarios.py finance/tests/test_crud_api.py
|
||||
git commit -m "feat: PATCH-Endpunkte fuer Szenario-Modifikatoren und -Einmalzahlungen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GUI-Umbau — eine Einträge-Tabelle je Szenario
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/templates/planning.html` (Szenario-Abschnitt: die beiden `<details>` „Modifikatoren" [Zeilen ~300-362] und „Einmalzahlungen in diesem Szenario" [~364-395] ersetzen; JS-Block: `onModKindChange` ersetzen)
|
||||
- Modify: `app/routers/gui.py` (`_scenario_rows`: Modifikator-Query stabil sortieren)
|
||||
- Modify: `app/templates/hilfe.html` (Szenarien-Absatz an neue Tabelle anpassen)
|
||||
- Modify: `tests/test_gui.py` (neuer Test + Anpassung `test_szenario_gui_ende_und_einmalzahlungen`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: PATCH-Endpunkte aus Task 1; `toggleEdit`, `onModTargetTypeChange`, Filter `|eur`/`|de_label`, Kontext `row.modifiers`/`row.planned_items`/`modifier_kinds`/`recurring_names`/`category_names` (alle vorhanden).
|
||||
- Produces: Element-IDs `mod-row-{id}`/`mod-edit-{id}` und `spi-row-{id}`/`spi-edit-{id}`; JS `onEntryArtChange(select)` (ersetzt `onModKindChange` vollständig — auch in den Art-Selects); Anlege-Formular mit `data-modifiers-url`/`data-planned-url` und Eintragsart-Option `value="einmal"`.
|
||||
|
||||
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` ergänzen (Imports `Scenario, ScenarioModifier, ScenarioPlannedItem` existieren dort seit A5):
|
||||
|
||||
```python
|
||||
def test_szenario_eintraege_tabelle(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Eintraege-GUI", description="")
|
||||
db.add(sc)
|
||||
db.flush()
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||
target_id=1, kind="ende", value=Decimal("0"),
|
||||
end_date=date(2026, 8, 31)))
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category",
|
||||
target_id=1, kind="percent", value=Decimal("25.00")))
|
||||
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
|
||||
assert "Einträge" in r
|
||||
# alte details-Abschnitte sind ersetzt
|
||||
assert "<summary>Modifikatoren</summary>" not in r
|
||||
assert "Einmalzahlungen in diesem Szenario" not in r
|
||||
# gemischte Zeilen in EINER Tabelle
|
||||
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
|
||||
assert ">Einmalzahlung<" in r
|
||||
# Inline-Edit je Typ
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
|
||||
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
|
||||
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
|
||||
```
|
||||
|
||||
Run: → FAIL
|
||||
|
||||
- [x] **Step 2: `_scenario_rows` sortieren** — `app/routers/gui.py`, Modifikator-Query um `.order_by(ScenarioModifier.id)` ergänzen:
|
||||
|
||||
```python
|
||||
modifiers = session.execute(
|
||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||
.order_by(ScenarioModifier.id)
|
||||
).scalars().all()
|
||||
```
|
||||
|
||||
- [x] **Step 3: Einträge-Tabelle** — in `planning.html` die BEIDEN `<details>`-Blöcke „Modifikatoren" und „Einmalzahlungen in diesem Szenario" komplett ersetzen durch:
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
- [x] **Step 4: Anlege-Formular „Neuer Eintrag"** — direkt nach der Tabelle (Default-Art ist die erste Option `percent` → Wert aktiv, Datumsfelder/Einmal-Felder disabled):
|
||||
|
||||
```html
|
||||
<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>
|
||||
<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>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>
|
||||
<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>
|
||||
```
|
||||
|
||||
WICHTIG: Der Ziel-Typ-Select muss VOR den beiden Ziel-Spans stehen wie im bisherigen Formular? Nein — Reihenfolge im bisherigen Formular war Ziel-Typ, dann Spans. Diese Reihenfolge beibehalten (Ziel-Typ zuerst, dann die Spans), damit das Formular lesbar bleibt — den obigen Block entsprechend anordnen: Eintragsart, Ziel-Typ, beide Spans, Wert, Endet am, Name, Betrag, Fällig am, Button.
|
||||
|
||||
- [x] **Step 5: JS** — im `<script>`-Block von `planning.html` die Funktion `onModKindChange` ERSETZEN durch:
|
||||
|
||||
```javascript
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`onModTargetTypeChange` und `toggleEdit` bleiben unverändert.
|
||||
|
||||
- [x] **Step 6: Bestehenden Test anpassen** — `tests/test_gui.py::test_szenario_gui_ende_und_einmalzahlungen` (aus A5 Task 7): die Assertions `"Einmalzahlungen in diesem Szenario" in r` und `f'hx-post="/api/scenarios/{sc.id}/planned"' in r` ersetzen durch:
|
||||
|
||||
```python
|
||||
assert "Einträge" in r
|
||||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||
```
|
||||
|
||||
Die übrigen Assertions (`value="ende"`, `name="end_date"`, `31.08.2026`, `Zufluss-GUI`, `5.000,00`, `30.07.2026`) bleiben gültig. Danach: `grep -n "Einmalzahlungen in diesem Szenario" tests/` → 0 Treffer.
|
||||
|
||||
- [x] **Step 7: Hilfe anpassen** — `app/templates/hilfe.html`, im Szenarien-Absatz (aus A5) den Satzteil zu Modifikatoren/Einmalzahlungen ersetzen durch (generisch, keine echten Daten):
|
||||
|
||||
```html
|
||||
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||
änderbar.
|
||||
```
|
||||
|
||||
- [x] **Step 8: Tests + Suite grün**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_gui.py -q && .venv/bin/python -m pytest -q`
|
||||
Expected: alles PASS
|
||||
|
||||
- [x] **Step 9: Fable-Testagent-Abnahme** — Live-Anteil (TestClient-Approximation): gemischte Tabelle rendert beide Typen in korrekter Reihenfolge; Edit-Roundtrips für Modifikator (percent→ende inkl. Datumspflicht) und Einmalzahlung mit deutscher Betragseingabe; Umschaltmatrix-Handtrace für alle 5 Eintragsarten (inkl. disabled-Zustand von `kind`-Fremdfeld beim jeweils anderen Endpunkt); keine doppelten IDs. Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/templates/ finance/app/routers/gui.py finance/tests/test_gui.py
|
||||
git commit -m "feat: Szenario-Eintraege als eine editierbare Tabelle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Release v0.7.0 — Redeploy, Live-Check, Ledger
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/VERSION` (`0.6.0` → `0.7.0`)
|
||||
- Modify: `.superpowers/sdd/progress.md` (Ledger), Plan-Häkchen in dieser Datei
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-2 committet, Suite grün, bestehendes Live-Szenario „Best Case" (id 4) mit 3 Einträgen.
|
||||
|
||||
- [x] **Step 1: Suite final** — `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` → alle grün.
|
||||
|
||||
- [x] **Step 2: Version + Commit**
|
||||
|
||||
```bash
|
||||
echo "0.7.0" > /home/wlfb/bin/finance/VERSION
|
||||
cd /home/wlfb/bin && git add finance/VERSION && git commit -m "chore: Version 0.7.0"
|
||||
```
|
||||
|
||||
- [x] **Step 3: Redeploy** — `cd /home/wlfb/bin && ./create_pod_finance.sh` (keine Migration nötig; Entrypoint-`alembic upgrade head` ist idempotent). Expected: Service aktiv, Readiness 200.
|
||||
|
||||
- [x] **Step 4: Live-Smoke** — `/api/version` == 0.7.0 (Bearer-Key aus `~/.local/share/finance_pod/.env`); GUI-Login; `/planung` zeigt im „Best Case"-Kasten die Einträge-Tabelle mit 3 Zeilen (2× Ende, 1× Einmalzahlung) OHNE aufklappen zu müssen; kein „Modifikatoren"-Summary mehr.
|
||||
|
||||
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: zusätzlich zu Step 4 ein kompletter Roundtrip in einem WEGWERF-Szenario „SMOKE-A6" (anlegen → Eintrag percent anlegen → per PATCH auf ende mit Datum ändern → Einmalzahlung anlegen → Betrag per PATCH ändern → Szenario löschen [räumt alles ab]); Bestandsdaten unverändert (confirmed-Tx-Count, „Best Case"-Einträge unangetastet). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 6: Ledger + Plan-Häkchen + Push**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add docs/superpowers/plans/2026-07-20-ausbaustufe-6.md && git commit -m "docs: Ledger/Plan Ausbaustufe 6" && git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (beim Planschreiben)
|
||||
|
||||
- **Spec-Abdeckung:** API-PATCH → Task 1; Tabelle/Anlege-Formular/Inline-Edit/JS/Sortierung/Hilfe → Task 2; Release/Live-Check → Task 3. Vollständig.
|
||||
- **Platzhalter:** keine.
|
||||
- **Typ-Konsistenz:** URLs/IDs (`mod-`/`spi-`), `data-modifiers-url`/`data-planned-url`, `onEntryArtChange` konsistent zwischen Tasks 1/2; Test-Anpassung des A5-Tests explizit benannt (Step 6), damit keine Suite-Regression entsteht.
|
||||
103
docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md
Normal file
103
docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Design — Ausbaustufe 6: Szenario-Einträge-Tabelle (v0.7.0)
|
||||
|
||||
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Freigabe zum
|
||||
> Direktdurchlauf Spec → Plan → Umsetzung). Anlass: Nutzerfeedback nach
|
||||
> v0.6.0 — die Szenario-Änderungen (Modifikatoren, Einmalzahlungen) waren
|
||||
> auf zwei zugeklappte `<details>`-Tabellen verteilt und nicht editierbar;
|
||||
> erwartet wurde EINE editierbare Tabelle aller Einträge (Zielbild: 15–20
|
||||
> Änderungen pro Szenario bequem pflegen).
|
||||
|
||||
## Ziel
|
||||
|
||||
Pro Szenario eine **immer sichtbare Tabelle „Einträge"**, die Modifikatoren
|
||||
und szenario-eigene Einmalzahlungen gemeinsam zeigt, mit Inline-Bearbeiten
|
||||
und Löschen je Zeile und einem gemeinsamen Anlege-Formular mit
|
||||
Eintragsart-Umschaltung.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- **Datenmodell unverändert.** „Eintrag" ist eine reine Darstellungssicht
|
||||
auf `ScenarioModifier` und `ScenarioPlannedItem`. Keine Migration.
|
||||
- **Typ bleibt beim Bearbeiten erhalten:** ein Modifikator kann Art
|
||||
(percent/absolute/remove/ende), Ziel und Wert/Datum ändern; eine
|
||||
Einmalzahlung bleibt Einmalzahlung (Name/Betrag/Datum änderbar).
|
||||
- „Kredite zuordnen" bleibt eigener Abschnitt (Zuordnung, kein Eintrag).
|
||||
- Beschreibung des Szenarios bleibt Freitext ohne Funktion.
|
||||
|
||||
## API
|
||||
|
||||
Zwei neue Endpunkte in `app/routers/scenarios.py` (Full-Body-Update — die
|
||||
Edit-Formulare senden immer alle Felder des Typs):
|
||||
|
||||
- `PATCH /api/scenarios/{id}/modifiers/{mod_id}` — Body = `ModifierIn`
|
||||
(inkl. bestehender Validierung: `ende` erfordert `end_date`, andere Arten
|
||||
nullen es). 404 „Modifikator nicht gefunden" bei fremder/fehlender ID
|
||||
(Muster `delete_modifier`). Antwort `ModifierOut`.
|
||||
- `PATCH /api/scenarios/{id}/planned/{item_id}` — Body =
|
||||
`ScenarioPlannedIn`. 404 „Einmalzahlung nicht gefunden" (Muster
|
||||
`delete_scenario_planned`). Antwort `ScenarioPlannedOut`.
|
||||
|
||||
Sortierung für stabile Anzeige: Modifikator-Query in `_scenario_rows`
|
||||
bekommt `.order_by(ScenarioModifier.id)`; Einmalzahlungen sind bereits nach
|
||||
`due` sortiert.
|
||||
|
||||
## GUI (`app/templates/planning.html`)
|
||||
|
||||
Die zwei `<details>`-Abschnitte „Modifikatoren" und „Einmalzahlungen in
|
||||
diesem Szenario" werden ersetzt durch eine offene Tabelle **„Einträge"**:
|
||||
|
||||
- Spalten: **Was** | **Art** | **Wert/Betrag** | **Datum** | Aktionen.
|
||||
- Modifikator: Was = „Posten: X" / „Kategorie: Y"; Art = `|de_label`;
|
||||
Wert = `value|eur` bei percent/absolute, sonst „–"; Datum =
|
||||
`end_date` TT.MM.JJJJ bei ende, sonst „–".
|
||||
- Einmalzahlung: Was = Name; Art = „Einmalzahlung"; Wert = `amount|eur €`
|
||||
(neg-Klasse wie bisher); Datum = `due` TT.MM.JJJJ.
|
||||
- Zeilen-Reihenfolge: erst Modifikatoren (nach id), dann Einmalzahlungen
|
||||
(nach Fälligkeit).
|
||||
- **Inline-Bearbeiten** je Zeile nach dem `toggleEdit`-Muster aus v0.6.0:
|
||||
IDs `mod-row-{id}`/`mod-edit-{id}` bzw. `spi-row-{id}`/`spi-edit-{id}`.
|
||||
Modifikator-Edit-Formular = Felder des Anlege-Formulars (Ziel-Typ, Ziel,
|
||||
Art, Wert, Datum, vorbelegt, mit denselben Umschaltern), sendet
|
||||
`hx-patch` auf den neuen Endpunkt. Einmalzahlungs-Edit: Name/Betrag/Datum.
|
||||
- **Ein Anlege-Formular „Neuer Eintrag"** mit Dropdown **Eintragsart**
|
||||
(Ende, Prozent, Absolut, Entfällt, Einmalzahlung; Werte
|
||||
`ende|percent|absolute|remove|einmal`):
|
||||
- Feld-Umschaltung ausschließlich per `disabled` (UX-Regel):
|
||||
Ziel-Typ/Ziel aktiv bei den vier Modifikator-Arten; Wert aktiv bei
|
||||
Prozent/Absolut; Datum „Endet am" (`end_date`) aktiv bei Ende;
|
||||
Name/Betrag/„Fällig am" (`due`) aktiv bei Einmalzahlung.
|
||||
- Der Eintragsart-Select trägt `name="kind"`; beim Absenden an den
|
||||
Einmalzahlungs-Endpunkt wird das überzählige `kind` von Pydantic
|
||||
ignoriert (Default `extra=ignore`), disabled-Felder fehlen in der
|
||||
Serialisierung.
|
||||
- Ziel-Endpunkt-Wechsel: JS setzt das `hx-post`-Attribut des Formulars um
|
||||
(`…/modifiers` ↔ `…/planned`) und ruft `htmx.process(form)` auf.
|
||||
- Bestehende Helfer (`onModTargetTypeChange`, `toggleEdit`) werden
|
||||
weiterverwendet; neue Funktion `onEntryArtChange(select)` ersetzt
|
||||
`onModKindChange` (Umschaltmatrix oben). Scoping wie bisher über
|
||||
`closest('form')`, damit Anlege- und Edit-Formulare sich nicht stören.
|
||||
|
||||
`hilfe.html`: der Szenarien-Absatz wird auf die neue Einträge-Tabelle
|
||||
angepasst (weiterhin generisch, keine echten Daten).
|
||||
|
||||
## Tests (TDD, Suite bleibt grün — Basis 178)
|
||||
|
||||
- API: PATCH Modifikator (Wert ändern; Art auf ende wechseln mit Datum;
|
||||
ende ohne Datum → 422; fremde scenario_id → 404), PATCH Einmalzahlung
|
||||
(Betrag/Datum ändern; fremde scenario_id → 404).
|
||||
- GUI: eine Einträge-Tabelle statt der zwei details-Abschnitte
|
||||
(Summary-Texte „Modifikatoren"/„Einmalzahlungen in diesem Szenario"
|
||||
verschwinden), gemischte Zeilen inkl. „Einmalzahlung"-Art, Edit-Formulare
|
||||
mit `hx-patch`-URLs, Anlege-Formular mit Eintragsart-Optionen.
|
||||
|
||||
## Release
|
||||
|
||||
`finance/VERSION` → `0.7.0`, Redeploy via `./create_pod_finance.sh` (keine
|
||||
Migration nötig), Live-Check am bestehenden Szenario „Best Case" (drei
|
||||
Einträge sichtbar in einer Tabelle, ein Bearbeiten-Roundtrip live).
|
||||
Fable-Testagent-Gate je Task vor Commit (CLAUDE.md). Datenschutz-Regel
|
||||
unverändert: echte Namen/Beträge nur Live-DB/Chat.
|
||||
|
||||
**Außerhalb des Scopes:** Typwechsel Modifikator↔Einmalzahlung beim
|
||||
Bearbeiten, Mehrfach-Bearbeitung/Bulk-Aktionen, Kredit-Zuordnung als
|
||||
Eintrag, Änderungen an Engine/Projektion.
|
||||
@@ -1 +1 @@
|
||||
0.6.0
|
||||
0.7.0
|
||||
|
||||
@@ -264,6 +264,7 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
|
||||
}
|
||||
modifiers = session.execute(
|
||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||
.order_by(ScenarioModifier.id)
|
||||
).scalars().all()
|
||||
planned_items = session.execute(
|
||||
select(ScenarioPlannedItem)
|
||||
|
||||
@@ -181,6 +181,21 @@ def delete_modifier(scenario_id: int, mod_id: int,
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
|
||||
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = session.get(ScenarioModifier, mod_id)
|
||||
if modifier is None or modifier.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(modifier, key, value)
|
||||
session.commit()
|
||||
session.refresh(modifier)
|
||||
return ModifierOut.model_validate(modifier)
|
||||
|
||||
|
||||
class ScenarioPlannedIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
@@ -225,6 +240,21 @@ def delete_scenario_planned(scenario_id: int, item_id: int,
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
|
||||
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update, Muster patch_modifier.
|
||||
_get_scenario(session, scenario_id)
|
||||
item = session.get(ScenarioPlannedItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioPlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
|
||||
def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
||||
start_date: date | None = None,
|
||||
|
||||
@@ -70,11 +70,13 @@
|
||||
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
||||
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
||||
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
||||
Varianten wie „Best Case“ entstehen über Modifikatoren: Art „Ende“ lässt
|
||||
einen wiederkehrenden Posten (z. B. eine auslaufende Miete) innerhalb des
|
||||
Szenarios zu einem Datum enden, ohne den Posten selbst zu ändern; „Prozent“/
|
||||
„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz aus. Szenario-eigene
|
||||
Einmalzahlungen (z. B. ein erwarteter Zufluss) zählen nur in ihrem Szenario.
|
||||
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||
änderbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||
|
||||
@@ -297,102 +297,138 @@
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Modifikatoren</summary>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in row.modifiers %}
|
||||
<tr>
|
||||
<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 == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}
|
||||
{% else %}{{ m.value|eur }}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
{% else %}
|
||||
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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"
|
||||
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">Kategorie</option>
|
||||
<option value="recurring">Wiederkehrender Posten</option>
|
||||
<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 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>Art
|
||||
<select name="kind" data-type="str" onchange="onModKindChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||
</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>
|
||||
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
||||
<label>Endet am <input type="date" name="end_date" disabled></label>
|
||||
<button type="submit">Modifikator hinzufügen</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Einmalzahlungen in diesem Szenario</summary>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for sp in row.planned_items %}
|
||||
<tr>
|
||||
<td>{{ sp.name }}</td>
|
||||
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||||
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<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>
|
||||
{% else %}
|
||||
<tr><td colspan="4">Keine szenario-eigenen Einmalzahlungen.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" required></label>
|
||||
<label>Fällig am <input type="date" name="due" required></label>
|
||||
<button type="submit">Einmalzahlung hinzufügen</button>
|
||||
</form>
|
||||
<p class="hint">Zählt nur in diesem Szenario — unabhängig von „Einmalposten einschließen“.</p>
|
||||
</details>
|
||||
</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()}">
|
||||
@@ -450,11 +486,37 @@
|
||||
recSelect.disabled = isCategory;
|
||||
}
|
||||
|
||||
function onModKindChange(select) {
|
||||
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 isEnde = select.value === 'ende';
|
||||
form.querySelector('[name="end_date"]').disabled = !isEnde;
|
||||
form.querySelector('[name="value"]').disabled = isEnde;
|
||||
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) {
|
||||
|
||||
@@ -229,3 +229,51 @@ def test_scenario_planned_crud_und_cleanup(client, db):
|
||||
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||||
from app.models.tables import ScenarioPlannedItem
|
||||
assert db.query(ScenarioPlannedItem).count() == 0
|
||||
|
||||
|
||||
def test_modifier_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
|
||||
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent",
|
||||
"value": "10.00"}).json()
|
||||
# Wert aendern
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
|
||||
assert r.status_code == 200 and r.json()["value"] == "25.00"
|
||||
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
|
||||
# ende ohne Datum -> 422
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||
assert r.status_code == 422
|
||||
# zurueck auf remove: end_date wird genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_scenario_planned_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
|
||||
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Zufluss geaendert"
|
||||
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 404
|
||||
|
||||
@@ -354,6 +354,36 @@ def test_szenario_gui_ende_und_einmalzahlungen(client, db):
|
||||
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
|
||||
assert "Einmalzahlungen in diesem Szenario" in r
|
||||
assert "Einträge" in r
|
||||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" in r
|
||||
assert f'hx-post="/api/scenarios/{sc.id}/planned"' in r
|
||||
|
||||
|
||||
def test_szenario_eintraege_tabelle(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Eintraege-GUI", description="")
|
||||
db.add(sc)
|
||||
db.flush()
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||
target_id=1, kind="ende", value=Decimal("0"),
|
||||
end_date=date(2026, 8, 31)))
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category",
|
||||
target_id=1, kind="percent", value=Decimal("25.00")))
|
||||
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
|
||||
assert "Einträge" in r
|
||||
# alte details-Abschnitte sind ersetzt
|
||||
assert "<summary>Modifikatoren</summary>" not in r
|
||||
assert "Einmalzahlungen in diesem Szenario" not in r
|
||||
# gemischte Zeilen in EINER Tabelle
|
||||
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
|
||||
assert ">Einmalzahlung<" in r
|
||||
# Inline-Edit je Typ
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
|
||||
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user