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:
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user