feat: Inline-Bearbeiten fuer Fixposten, Einmalposten, Kredite und Szenario-Kopf

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 11:42:04 +02:00
parent cbcf2e0439
commit aa1884c31e
2 changed files with 106 additions and 5 deletions

View File

@@ -11,7 +11,7 @@
</thead> </thead>
<tbody> <tbody>
{% for r in recurring %} {% for r in recurring %}
<tr> <tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td> <td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td> <td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td> <td>{{ r.rhythm|de_label }}</td>
@@ -20,6 +20,7 @@
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '' }}</td> <td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '' }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td> <td>{{ category_names.get(r.category_id, '') }}</td>
<td> <td>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none" <form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
hx-confirm="„{{ r.name }}“ wirklich löschen?" hx-confirm="„{{ r.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -27,6 +28,31 @@
</form> </form>
</td> </td>
</tr> </tr>
<tr id="rec-edit-{{ r.id }}" hidden>
<td colspan="8">
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ r.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ r.amount|eur }}" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == r.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="{{ r.due_day }}" required></label>
<label>Start <input type="date" name="start_date" value="{{ r.start_date.isoformat() if r.start_date else '' }}"></label>
<label>Ende <input type="date" name="end_date" value="{{ r.end_date.isoformat() if r.end_date else '' }}"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == r.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %} {% else %}
<tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr> <tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %} {% endfor %}
@@ -100,12 +126,13 @@
</thead> </thead>
<tbody> <tbody>
{% for p in planned %} {% for p in planned %}
<tr> <tr id="pln-row-{{ p.id }}">
<td>{{ p.name }}</td> <td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td> <td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td> <td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td> <td>{{ category_names.get(p.category_id, '') }}</td>
<td> <td>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none" <form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
hx-confirm="„{{ p.name }}“ wirklich löschen?" hx-confirm="„{{ p.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -113,6 +140,24 @@
</form> </form>
</td> </td>
</tr> </tr>
<tr id="pln-edit-{{ p.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ p.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ p.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ p.due.isoformat() }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == p.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %} {% else %}
<tr><td colspan="5">Noch keine Einmalposten.</td></tr> <tr><td colspan="5">Noch keine Einmalposten.</td></tr>
{% endfor %} {% endfor %}
@@ -145,7 +190,7 @@
</thead> </thead>
<tbody> <tbody>
{% for l in loans %} {% for l in loans %}
<tr> <tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td> <td>{{ l.name }}</td>
<td>{{ l.principal|eur }} €</td> <td>{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td> <td>{{ l.annual_rate_pct|eur }} %</td>
@@ -153,6 +198,7 @@
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td> <td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type|de_label }}</td> <td>{{ l.repayment_type|de_label }}</td>
<td> <td>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none" <form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
hx-confirm="„{{ l.name }}“ wirklich löschen?" hx-confirm="„{{ l.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -160,6 +206,27 @@
</form> </form>
</td> </td>
</tr> </tr>
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
muessen betroffene Szenarien neu durchgerechnet werden. -->
<tr id="loan-edit-{{ l.id }}" hidden>
<td colspan="7">
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}" {% if rt == l.repayment_type %}selected{% endif %}>{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
<tr> <tr>
<td colspan="7"> <td colspan="7">
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}"> <details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
@@ -199,11 +266,23 @@
{% set sc = row.scenario %} {% set sc = row.scenario %}
<fieldset> <fieldset>
<legend>{{ sc.name }}</legend> <legend>{{ sc.name }}</legend>
<p> <p id="sc-row-{{ sc.id }}">
{{ sc.description }} {{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }} · Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }} · Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
</p> </p>
<div id="sc-edit-{{ sc.id }}" hidden>
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
</form>
</div>
<details> <details>
<summary>Kredite zuordnen</summary> <summary>Kredite zuordnen</summary>
@@ -315,6 +394,12 @@
// json-form-Extension ist zentral in base.html registriert (eine // json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling). // Definition fuer alle Templates, inkl. data-type="bool"-Handling).
function toggleEdit(prefix, id, editing) {
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
function onModTargetTypeChange(select) { function onModTargetTypeChange(select) {
var form = select.closest('form'); var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category'); var catSpan = form.querySelector('.mod-target-category');

View File

@@ -2,7 +2,7 @@ from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from app.formats import eur from app.formats import eur
from app.models.tables import Account, RecurringItem from app.models.tables import Account, Loan, PlannedItem, RecurringItem, Scenario
H = {"Authorization": "Bearer test-key"} H = {"Authorization": "Bearer test-key"}
@@ -309,6 +309,22 @@ def test_salden_stichtag_garbage_falls_back_to_today(client):
assert date.today().strftime('%d.%m.%Y') in r.text assert date.today().strftime('%d.%m.%Y') in r.text
def test_planung_hat_bearbeiten_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="EditR", amount=Decimal("-5.00"), rhythm="monthly", due_day=1))
db.add(PlannedItem(name="EditP", amount=Decimal("-7.00"), due=date(2026, 8, 1)))
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/planung").text
assert r.count(">Bearbeiten</button>") >= 4
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'):
assert fragment in r, fragment
def test_salden_shows_ohne_anker_footnote(client, db): def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte # UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt. # gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.