feat: Szenarien-Seite mit zwei Formularen, Zeitraum-Spalte und wiederkehrenden Szenario-Zahlungen (A14-T4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 13:08:16 +02:00
co-authored by Claude Opus 5.5
parent 8911623c15
commit cd7ba6aa6a
8 changed files with 373 additions and 106 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ DE_LABELS = {
"annuity": "Annuität", "annuity": "Annuität",
"bullet": "endfällig", "bullet": "endfällig",
"percent": "Prozent", "percent": "Prozent",
"absolute": "Absolut", "absolute": "Kürzung (€)",
"set": "Neuer Betrag",
"remove": "Entfällt", "remove": "Entfällt",
"ende": "Ende", "ende": "Ende",
} }
+9 -2
View File
@@ -21,7 +21,8 @@ from app.formats import de_label, eur, pct
from app.models.tables import (Account, Category, PlannedItem, from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem, ProjectionResult, RecurringItem,
Scenario, ScenarioLoan, ScenarioModifier, Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction) ScenarioPlannedItem, ScenarioRecurringItem,
Transaction)
from app.routers.imports import list_imports as _list_imports from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans from app.routers.planning import list_loans as _list_loans
@@ -322,11 +323,16 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
select(ScenarioPlannedItem) select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id) .where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all() .order_by(ScenarioPlannedItem.due)).scalars().all()
recurring_items = session.execute(
select(ScenarioRecurringItem)
.where(ScenarioRecurringItem.scenario_id == sc.id)
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars().all()
rows.append({ rows.append({
"scenario": sc, "scenario": sc,
"assigned_loan_ids": assigned_loan_ids, "assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers, "modifiers": modifiers,
"planned_items": planned_items, "planned_items": planned_items,
"recurring_items": recurring_items,
"result": session.get(ProjectionResult, sc.id), "result": session.get(ProjectionResult, sc.id),
}) })
return rows return rows
@@ -378,7 +384,8 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)):
"recurring_names": {r.id: r.name for r in recurring}, "recurring_names": {r.id: r.name for r in recurring},
"loans": loans, "loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans), "scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"], "modifier_kinds": ["percent", "absolute", "set", "remove", "ende"],
"rhythms": ["monthly", "quarterly", "yearly"],
}) })
+72
View File
@@ -0,0 +1,72 @@
/* Szenarien-Seite (Ausbaustufe 14): Umschaltlogik der Formulare
* „Neue Änderung an Bestehendem" (.aenderung-form, auch in den Edit-Zeilen
* der Einträge) und „Neue Zahlung nur in diesem Szenario" (.zahlung-form).
* UX-Regel: Felder werden nur per disabled gesperrt, nie entfernt;
* gesperrte Felder tragen den Tooltip TIP. Gesperrte Felder sendet htmx
* nicht mit.
*/
(function () {
'use strict';
var TIP = 'Für diese Eintragsart nicht relevant';
var WERT_LABEL = { percent: 'Prozentsatz (%)', absolute: 'Kürzung (€)', set: 'Neuer Betrag (€)' };
function sperren(el, gesperrt) {
if (!el) { return; }
el.disabled = gesperrt;
if (gesperrt) { el.title = TIP; } else { el.removeAttribute('title'); }
}
function feld(form, name) { return form.querySelector('[name="' + name + '"]'); }
function beschriften(form, cls, text) {
var el = form.querySelector('.' + cls);
if (el) { el.textContent = text; }
}
// Ziel-Typ: passende Auswahl (Kategorie oder Posten) zeigen und freigeben.
function zielTyp(select) {
var form = select.closest('form');
var istKategorie = select.value === 'category';
var kat = form.querySelector('.mod-target-category');
var rec = form.querySelector('.mod-target-recurring');
kat.style.display = istKategorie ? '' : 'none';
rec.style.display = istKategorie ? 'none' : '';
sperren(kat.querySelector('select'), !istKategorie);
sperren(rec.querySelector('select'), istKategorie);
}
function aenderungArt(select) {
var form = select.closest('form');
var art = select.value;
var mitWert = Object.prototype.hasOwnProperty.call(WERT_LABEL, art);
sperren(feld(form, 'value'), !mitWert);
beschriften(form, 'value-label', mitWert ? WERT_LABEL[art] : 'Wert');
sperren(feld(form, 'start_date'), art === 'ende');
beschriften(form, 'bis-label', art === 'ende' ? 'Endet am' : 'Gültig bis');
var typ = feld(form, 'target_type');
var kategorieOption = typ.querySelector('option[value="category"]');
// „Neuer Betrag" gibt es nur für Posten.
if (art === 'set') { typ.value = 'recurring'; }
kategorieOption.disabled = art === 'set';
zielTyp(typ);
}
function zahlungArt(select) {
var form = select.closest('form');
var einmal = select.value === 'einmal';
sperren(feld(form, 'due'), !einmal);
['rhythm', 'due_day', 'start_date', 'end_date'].forEach(function (name) {
sperren(feld(form, name), einmal);
});
form.setAttribute('hx-post', form.getAttribute(einmal ? 'data-planned-url' : 'data-recurring-url'));
if (window.htmx) { window.htmx.process(form); }
}
function init(root) {
root.querySelectorAll('.aenderung-form select[name="kind"]').forEach(aenderungArt);
root.querySelectorAll('.zahlung-form select.zahlung-art').forEach(zahlungArt);
}
window.FBSzenario = { zielTyp: zielTyp, aenderungArt: aenderungArt, zahlungArt: zahlungArt, init: init };
})();
+2
View File
@@ -8,6 +8,7 @@
<script src="/static/htmx.min.js?v={{ app_version }}"></script> <script src="/static/htmx.min.js?v={{ app_version }}"></script>
<script src="/static/sort.js?v={{ app_version }}"></script> <script src="/static/sort.js?v={{ app_version }}"></script>
<script src="/static/doppelzaehlung.js?v={{ app_version }}"></script> <script src="/static/doppelzaehlung.js?v={{ app_version }}"></script>
<script src="/static/szenarien.js?v={{ app_version }}"></script>
</head> </head>
<body> <body>
<nav> <nav>
@@ -100,6 +101,7 @@
} }
if (window.FBDoppel) { window.FBDoppel.init(document); } if (window.FBDoppel) { window.FBDoppel.init(document); }
if (window.FBSzenario) { window.FBSzenario.init(document); }
</script> </script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer> <footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body> </body>
+112 -96
View File
@@ -1,6 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Szenarien – Finanzberatung{% endblock %} {% block title %}Szenarien – Finanzberatung{% endblock %}
{% block content %} {% block content %}
{% macro zeitraum(von, bis) -%}
{%- if von and bis -%}{{ von.strftime('%d.%m.%Y') }} – {{ bis.strftime('%d.%m.%Y') }}
{%- elif von -%}ab {{ von.strftime('%d.%m.%Y') }}
{%- elif bis -%}bis {{ bis.strftime('%d.%m.%Y') }}
{%- else -%}–{%- endif -%}
{%- endmacro %}
{% set TIP = 'Für diese Eintragsart nicht relevant' %}
<h1>Szenarien</h1> <h1>Szenarien</h1>
<section class="planning-section"> <section class="planning-section">
@@ -45,7 +53,7 @@
<h3>Einträge</h3> <h3>Einträge</h3>
<table> <table>
<thead> <thead>
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr> <tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Zeitraum</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for m in row.modifiers %} {% for m in row.modifiers %}
@@ -55,8 +63,8 @@
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %} {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td> </td>
<td>{{ m.kind|de_label }}</td> <td>{{ m.kind|de_label }}</td>
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td> <td>{% if m.kind in ('percent', 'absolute', 'set') %}{{ 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>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}{{ zeitraum(m.start_date, m.end_date) }}{% endif %}</td>
<td> <td>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button> <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" <form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
@@ -67,10 +75,15 @@
</tr> </tr>
<tr id="mod-edit-{{ m.id }}" hidden data-sort-follow> <tr id="mod-edit-{{ m.id }}" hidden data-sort-follow>
<td colspan="5"> <td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none" <form class="aenderung-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()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Art
<select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Ziel-Typ <label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)"> <select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option> <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> <option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
</select> </select>
@@ -89,22 +102,59 @@
</select> </select>
</label> </label>
</span> </span>
<label>Art <label><span class="value-label">Wert</span> <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute', 'set') %}disabled title="{{ TIP }}"{% endif %}></label>
<select name="kind" data-type="str" onchange="onEntryArtChange(this)"> <label>Gültig ab <input type="date" name="start_date" value="{{ m.start_date.isoformat() if m.start_date else '' }}" {% if m.kind == 'ende' %}disabled title="{{ TIP }}"{% endif %}></label>
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %} <label><span class="bis-label">{{ 'Endet am' if m.kind == 'ende' else 'Gültig bis' }}</span> <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}"></label>
</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="submit">Speichern</button>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button> <button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
</form> </form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% for ri in row.recurring_items %}
<tr id="sri-row-{{ ri.id }}">
<td>{{ ri.name }}{% if ri.category_id %} <span class="muted">({{ category_names.get(ri.category_id, '') }})</span>{% endif %}</td>
<td>Wiederkehrend ({{ ri.rhythm|de_label }}, am {{ ri.due_day }}.)</td>
<td class="{{ 'neg' if ri.amount < 0 else '' }}">{{ ri.amount|eur }} €</td>
<td>{{ zeitraum(ri.start_date, ri.end_date) }}</td>
<td>
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
hx-confirm="„{{ ri.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="sri-edit-{{ ri.id }}" hidden data-sort-follow>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ ri.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ ri.amount|eur }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">(keine)</option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == ri.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == ri.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ ri.due_day }}" required></label>
<label>Start <input type="date" name="start_date" value="{{ ri.start_date.isoformat() if ri.start_date else '' }}"></label>
<label>Ende <input type="date" name="end_date" value="{{ ri.end_date.isoformat() if ri.end_date else '' }}"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% for sp in row.planned_items %} {% for sp in row.planned_items %}
<tr id="spi-row-{{ sp.id }}"> <tr id="spi-row-{{ sp.id }}">
<td>{{ sp.name }}</td> <td>{{ sp.name }}{% if sp.category_id %} <span class="muted">({{ category_names.get(sp.category_id, '') }})</span>{% endif %}</td>
<td>Einmalzahlung</td> <td>Einmalzahlung</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td> <td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td> <td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
@@ -123,6 +173,12 @@
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label> <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>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">(keine)</option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == sp.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label> <label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button> <button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
@@ -130,28 +186,25 @@
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not row.modifiers and not row.planned_items %} {% if not row.modifiers and not row.planned_items and not row.recurring_items %}
<tr><td colspan="5">Keine Einträge.</td></tr> <tr><td colspan="5">Keine Einträge.</td></tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
<h4>Neuer Eintrag</h4> <h4>Neue Änderung an Bestehendem</h4>
<form class="entry-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none" <form class="entry-form aenderung-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()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<div class="entry-art"> <div class="entry-art">
<label>Eintragsart <label>Art
<select name="kind" data-type="str" onchange="onEntryArtChange(this)"> <select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %} {% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select> </select>
</label> </label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span> <span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div> </div>
<label>Ziel-Typ <label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)"> <select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
<option value="category">Kategorie</option> <option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option> <option value="recurring">Wiederkehrender Posten</option>
</select> </select>
@@ -165,16 +218,48 @@
</span> </span>
<span class="mod-target-recurring" style="display:none"> <span class="mod-target-recurring" style="display:none">
<label>Posten <label>Posten
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant"> <select name="target_id" data-type="int" disabled title="{{ TIP }}">
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %} {% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select> </select>
</label> </label>
</span> </span>
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label> <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>Gültig ab <input type="date" name="start_date"></label>
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label> <label><span class="bis-label">Gültig bis</span> <input type="date" name="end_date"></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label> <button type="submit">Hinzufügen</button>
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label> </form>
<h4>Neue Zahlung nur in diesem Szenario</h4>
<form class="entry-form zahlung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
data-recurring-url="/api/scenarios/{{ sc.id }}/recurring"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<div class="entry-art">
<label>Art
<select class="zahlung-art" onchange="FBSzenario.zahlungArt(this)">
<option value="einmal">Einmalzahlung</option>
<option value="wiederkehrend">Wiederkehrend</option>
</select>
</label>
<span class="muted">— zählt nur in diesem Szenario, auch ohne „Wiederkehrende Posten/Einmalposten einschließen“.</span>
</div>
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-250,00" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">(keine)</option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<label>Fällig am <input type="date" name="due" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str" disabled title="{{ TIP }}">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required disabled title="{{ TIP }}"></label>
<label>Start <input type="date" name="start_date" disabled title="{{ TIP }}"></label>
<label>Ende <input type="date" name="end_date" disabled title="{{ TIP }}"></label>
<button type="submit">Hinzufügen</button> <button type="submit">Hinzufügen</button>
</form> </form>
@@ -215,75 +300,6 @@
</section> </section>
<script> <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) { function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' }) fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) { .then(function (resp) {
+116
View File
@@ -0,0 +1,116 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
let JSDOM = null;
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/szenarien.js'), 'utf8');
const TIP = 'Für diese Eintragsart nicht relevant';
const HTML = `
<form id="a" class="entry-form aenderung-form" hx-post="/api/scenarios/1/modifiers">
<select name="kind"><option value="percent">Prozent</option><option value="absolute">Kürzung (€)</option>
<option value="set">Neuer Betrag</option><option value="remove">Entfällt</option><option value="ende">Ende</option></select>
<select name="target_type"><option value="category">Kategorie</option><option value="recurring">Wiederkehrender Posten</option></select>
<span class="mod-target-category"><select name="target_id"><option value="1">K</option></select></span>
<span class="mod-target-recurring" style="display:none"><select name="target_id" disabled><option value="2">P</option></select></span>
<label><span class="value-label">Prozentsatz (%)</span><input name="value"></label>
<input type="date" name="start_date">
<label><span class="bis-label">Gültig bis</span><input type="date" name="end_date"></label>
</form>
<form id="z" class="entry-form zahlung-form" hx-post="/api/scenarios/1/planned"
data-planned-url="/api/scenarios/1/planned" data-recurring-url="/api/scenarios/1/recurring">
<select class="zahlung-art"><option value="einmal">Einmalzahlung</option><option value="wiederkehrend">Wiederkehrend</option></select>
<input name="name"><input name="amount"><select name="category_id"><option value="">(keine)</option></select>
<input type="date" name="due">
<select name="rhythm"><option value="monthly">monatlich</option></select>
<input name="due_day"><input type="date" name="start_date"><input type="date" name="end_date">
</form>`;
function load() {
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
const w = dom.window;
const processed = [];
w.htmx = { process: (el) => processed.push(el.id) };
w.eval(SRC);
w.FBSzenario.init(w.document);
const d = w.document;
return { w, a: d.getElementById('a'), z: d.getElementById('z'), processed };
}
function f(form, name) { return form.querySelector(`[name="${name}"]`); }
function art(w, form, wert) {
const sel = form.querySelector('[name="kind"]');
sel.value = wert;
w.FBSzenario.aenderungArt(sel);
}
test('Prozent: Wert und Zeitraum aktiv', { skip: !JSDOM }, () => {
const { a } = load();
assert.equal(f(a, 'value').disabled, false);
assert.equal(a.querySelector('.value-label').textContent, 'Prozentsatz (%)');
assert.equal(f(a, 'start_date').disabled, false);
assert.equal(f(a, 'end_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
});
test('Kürzung: Label Kürzung (€)', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'absolute');
assert.equal(a.querySelector('.value-label').textContent, 'Kürzung (€)');
});
test('Neuer Betrag: Ziel-Typ fest auf Posten, Kategorie gesperrt', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'set');
const typ = f(a, 'target_type');
assert.equal(typ.value, 'recurring');
assert.equal(typ.querySelector('option[value="category"]').disabled, true);
assert.equal(a.querySelector('.mod-target-recurring select').disabled, false);
assert.equal(a.querySelector('.mod-target-category select').disabled, true);
assert.equal(a.querySelector('.value-label').textContent, 'Neuer Betrag (€)');
art(w, a, 'percent');
assert.equal(typ.querySelector('option[value="category"]').disabled, false);
});
test('Ende: Gültig ab und Wert gesperrt, Label Endet am', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'ende');
assert.equal(f(a, 'start_date').disabled, true);
assert.equal(f(a, 'start_date').title, TIP);
assert.equal(f(a, 'value').disabled, true);
assert.equal(f(a, 'end_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Endet am');
});
test('Entfällt: Wert gesperrt, Zeitraum aktiv', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'remove');
assert.equal(f(a, 'value').disabled, true);
assert.equal(a.querySelector('.value-label').textContent, 'Wert');
assert.equal(f(a, 'start_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
});
test('Zahlung: Einmal → Wiederkehrend → Einmal', { skip: !JSDOM }, () => {
const { w, z, processed } = load();
const sel = z.querySelector('.zahlung-art');
const wiederkehrend = ['rhythm', 'due_day', 'start_date', 'end_date'];
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
assert.equal(f(z, 'due').disabled, false);
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
sel.value = 'wiederkehrend';
w.FBSzenario.zahlungArt(sel);
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/recurring');
assert.equal(f(z, 'due').disabled, true);
assert.equal(f(z, 'due').title, TIP);
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, false, n));
sel.value = 'einmal';
w.FBSzenario.zahlungArt(sel);
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
['name', 'amount', 'category_id'].forEach((n) => assert.equal(f(z, n).disabled, false, n));
assert.ok(processed.includes('z'));
});
+3 -2
View File
@@ -35,9 +35,10 @@ def test_de_label_bekannte_und_unbekannte_werte():
assert de_label("annuity") == "Annuität" assert de_label("annuity") == "Annuität"
assert de_label("bullet") == "endfällig" assert de_label("bullet") == "endfällig"
assert de_label("percent") == "Prozent" assert de_label("percent") == "Prozent"
assert de_label("absolute") == "Absolut" assert de_label("absolute") == "Kürzung (€)"
assert de_label("set") == "Neuer Betrag"
assert de_label("remove") == "Entfällt" assert de_label("remove") == "Entfällt"
assert de_label("ende") == "Ende" assert de_label("ende") == "Ende"
assert de_label("weirdvalue") == "weirdvalue" assert de_label("weirdvalue") == "weirdvalue"
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity", assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
"bullet", "percent", "absolute", "remove", "ende"} "bullet", "percent", "absolute", "set", "remove", "ende"}
+57 -5
View File
@@ -4,8 +4,9 @@ from decimal import Decimal
from app.config import get_settings from app.config import get_settings
from app.formats import eur from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem, from app.models.tables import (Account, Category, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem) Scenario, ScenarioModifier, ScenarioPlannedItem,
ScenarioRecurringItem)
H = {"Authorization": "Bearer test-key"} H = {"Authorization": "Bearer test-key"}
@@ -432,7 +433,7 @@ def test_szenario_eintraege_tabelle(client, db):
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute # Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
assert 'value="einmal"' in r assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r assert "FBSzenario.aenderungArt" in r and "onEntryArtChange" not in r
def test_szenarien_seite_und_nav(client, db): def test_szenarien_seite_und_nav(client, db):
@@ -459,7 +460,7 @@ def test_neuer_eintrag_formular_struktur(client, db):
db.add(Scenario(name="UX-Test", description="")) db.add(Scenario(name="UX-Test", description=""))
db.commit() db.commit()
r = client.get("/szenarien").text r = client.get("/szenarien").text
assert 'class="entry-form"' in r assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
@@ -650,6 +651,57 @@ def test_statische_dateien_mit_versionskennung(client):
assert re.search(r'/static/[^"?]+"', login) is None assert re.search(r'/static/[^"?]+"', login) is None
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text html = client.get("/").text
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js"): for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js", "szenarien.js"):
assert f'/static/{datei}?v={v}"' in html assert f'/static/{datei}?v={v}"' in html
assert re.search(r'/static/[^"?]+"', html) is None assert re.search(r'/static/[^"?]+"', html) is None
def test_szenario_zwei_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Formulare-A14", description="")
db.add(sc)
db.commit()
r = client.get("/szenarien").text
assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r
assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r
for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"',
'name="due_day"', 'name="category_id"', 'name="due"'):
assert feld in r, feld
assert 'class="zahlung-art"' in r and '<option value="">(keine)</option>' in r
assert 'class="bis-label"' in r
def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
cat = Category(name="Kat-Anzeige")
sc = Scenario(name="Anzeige-A14", description="")
db.add_all([cat, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)),
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="set", value=Decimal("-80.00"),
start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)),
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)),
ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"),
rhythm="monthly", due_day=14, start_date=date(2026, 10, 16),
category_id=cat.id),
ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"),
due=date(2026, 12, 1), category_id=cat.id),
])
db.commit()
r = client.get("/szenarien").text
assert "<th>Zeitraum</th>" in r
assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r
assert ">Neuer Betrag<" in r and "-80,00" in r
assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r
assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r
assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r
assert "Kat-Anzeige" in r and "Zahlung-E" in r
assert 'id="sri-row-' in r and 'id="sri-edit-' in r