From cb44194daf0f33f5165b10ab14c1b9ca0d8033d8633236de8a6bda5b0c5cf1f8 Mon Sep 17 00:00:00 2001 From: wlfb Date: Mon, 20 Jul 2026 12:51:04 +0200 Subject: [PATCH] feat: Szenario-Eintraege als eine editierbare Tabelle Co-Authored-By: Claude Fable 5 --- finance/app/routers/gui.py | 1 + finance/app/templates/hilfe.html | 12 +- finance/app/templates/planning.html | 252 +++++++++++++++++----------- finance/tests/test_gui.py | 34 +++- 4 files changed, 197 insertions(+), 102 deletions(-) diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index 6a4dedc..7c79b2d 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -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) diff --git a/finance/app/templates/hilfe.html b/finance/app/templates/hilfe.html index 649bbc5..12d0cde 100644 --- a/finance/app/templates/hilfe.html +++ b/finance/app/templates/hilfe.html @@ -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.
  • Grafana: Kontostand-Verläufe, Monatsausgaben nach diff --git a/finance/app/templates/planning.html b/finance/app/templates/planning.html index 811d3f5..2ac17e5 100644 --- a/finance/app/templates/planning.html +++ b/finance/app/templates/planning.html @@ -297,102 +297,138 @@ {% endfor %} -
    - Modifikatoren - - - - - - {% for m in row.modifiers %} - - - - - - - {% else %} - - {% endfor %} - -
    ZielArtWert
    - {% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }} - {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %} - {{ m.kind|de_label }} - {% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }} - {% else %}{{ m.value|eur }}{% endif %} - -
    - -
    -
    Keine Modifikatoren.
    +

    Einträge

    + + + + + + {% for m in row.modifiers %} + + + + + + + + + + + {% endfor %} + {% for sp in row.planned_items %} + + + + + + + + + + + {% endfor %} + {% if not row.modifiers and not row.planned_items %} + + {% endif %} + +
    WasArtWert/BetragDatum
    + {% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }} + {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %} + {{ m.kind|de_label }}{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %} + +
    + +
    +
    {{ sp.name }}Einmalzahlung{{ sp.amount|eur }} €{{ sp.due.strftime('%d.%m.%Y') }} + +
    + +
    +
    Keine Einträge.
    -
    - + + + - - - - - - - - - -
    - -
    - Einmalzahlungen in diesem Szenario - - - - {% for sp in row.planned_items %} - - - - - - - {% else %} - - {% endfor %} - -
    NameBetragFällig am
    {{ sp.name }}{{ sp.amount|eur }} €{{ sp.due.strftime('%d.%m.%Y') }} -
    - -
    -
    Keine szenario-eigenen Einmalzahlungen.
    -
    - - - - -
    -

    Zählt nur in diesem Szenario — unabhängig von „Einmalposten einschließen“.

    -
    + + + + + + + +
    @@ -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) { diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py index 08658d2..76a2fcc 100644 --- a/finance/tests/test_gui.py +++ b/finance/tests/test_gui.py @@ -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 "Modifikatoren" 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