feat: Szenario-Eintraege als eine editierbare Tabelle
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -264,6 +264,7 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
|
|||||||
}
|
}
|
||||||
modifiers = session.execute(
|
modifiers = session.execute(
|
||||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||||
|
.order_by(ScenarioModifier.id)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
planned_items = session.execute(
|
planned_items = session.execute(
|
||||||
select(ScenarioPlannedItem)
|
select(ScenarioPlannedItem)
|
||||||
|
|||||||
@@ -70,11 +70,13 @@
|
|||||||
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
||||||
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
||||||
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
||||||
Varianten wie „Best Case“ entstehen über Modifikatoren: Art „Ende“ lässt
|
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||||
einen wiederkehrenden Posten (z. B. eine auslaufende Miete) innerhalb des
|
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||||
Szenarios zu einem Datum enden, ohne den Posten selbst zu ändern; „Prozent“/
|
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||||
„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz aus. Szenario-eigene
|
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||||
Einmalzahlungen (z. B. ein erwarteter Zufluss) zählen nur in ihrem Szenario.
|
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>
|
||||||
<li>
|
<li>
|
||||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||||
|
|||||||
@@ -297,102 +297,138 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<h3>Einträge</h3>
|
||||||
<summary>Modifikatoren</summary>
|
<table>
|
||||||
<table>
|
<thead>
|
||||||
<thead>
|
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
|
||||||
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{% for m in row.modifiers %}
|
||||||
{% for m in row.modifiers %}
|
<tr id="mod-row-{{ m.id }}">
|
||||||
<tr>
|
<td>
|
||||||
<td>
|
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||||||
{% 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 %}
|
||||||
{% 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>
|
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td>
|
||||||
{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}
|
<td>
|
||||||
{% else %}{{ m.value|eur }}{% endif %}
|
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||||||
</td>
|
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||||
<td>
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
<button type="submit">Löschen</button>
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
</form>
|
||||||
<button type="submit">Löschen</button>
|
</td>
|
||||||
</form>
|
</tr>
|
||||||
</td>
|
<tr id="mod-edit-{{ m.id }}" hidden>
|
||||||
</tr>
|
<td colspan="5">
|
||||||
{% else %}
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||||
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
{% endfor %}
|
<label>Ziel-Typ
|
||||||
</tbody>
|
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||||
</table>
|
<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"
|
<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()}">
|
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
|
||||||
<label>Ziel-Typ
|
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
<option value="category">Kategorie</option>
|
<strong>Neuer Eintrag:</strong>
|
||||||
<option value="recurring">Wiederkehrender Posten</option>
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="mod-target-category">
|
</span>
|
||||||
<label>Kategorie
|
<span class="mod-target-recurring" style="display:none">
|
||||||
<select name="target_id" data-type="int">
|
<label>Posten
|
||||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
<select name="target_id" data-type="int" disabled>
|
||||||
</select>
|
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||||
</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 %}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
</span>
|
||||||
<label>Endet am <input type="date" name="end_date" disabled></label>
|
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
||||||
<button type="submit">Modifikator hinzufügen</button>
|
<label>Endet am <input type="date" name="end_date" disabled></label>
|
||||||
</form>
|
<label>Name <input type="text" name="name" disabled></label>
|
||||||
</details>
|
<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>
|
||||||
<details>
|
<button type="submit">Hinzufügen</button>
|
||||||
<summary>Einmalzahlungen in diesem Szenario</summary>
|
</form>
|
||||||
<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>
|
|
||||||
|
|
||||||
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
<form hx-post="/api/scenarios/{{ sc.id }}/project" 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()}">
|
||||||
@@ -450,11 +486,37 @@
|
|||||||
recSelect.disabled = isCategory;
|
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 form = select.closest('form');
|
||||||
var isEnde = select.value === 'ende';
|
var art = select.value;
|
||||||
form.querySelector('[name="end_date"]').disabled = !isEnde;
|
var isMod = art !== 'einmal';
|
||||||
form.querySelector('[name="value"]').disabled = isEnde;
|
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) {
|
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
||||||
|
|||||||
@@ -354,6 +354,36 @@ def test_szenario_gui_ende_und_einmalzahlungen(client, db):
|
|||||||
assert 'value="ende"' in r # neue Art im Dropdown
|
assert 'value="ende"' in r # neue Art im Dropdown
|
||||||
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
|
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
|
||||||
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
|
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 "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