Files
bin/finance/app/templates/planning.html
T
rdmsandClaude Opus 5.5 4458300f43 feat: Tabellen per Spaltenkopf sortierbar (A11-T4)
Neues static/sort.js sortiert Tabellen clientseitig per Klick auf den
Spaltenkopf (⇅/▲/▼): Datum TT.MM.JJJJ, deutsche Zahlen/Beträge, sonst
Text (Intl.Collator 'de'). Sortierwert optional per td[data-sort]
(Punkt-Dezimal/ISO) überschreibbar, th[data-nosort] bekommt keinen
Button, table[data-sort-mode="server"] wird übersprungen (Buchungen
sortiert serverseitig, folgt in Task 5). Bearbeiten-/Tilgungsplan-
Zeilen (tr[data-sort-follow]) wandern mit ihrer Vorzeile mit.
Initialisierung über htmx.onLoad, greift auch bei nachgeladenen
Fragmenten.

Templates entsprechend annotiert (Planung, Szenarien, Admin, Index,
Salden, Import-Liste, Buchungen); Salden-Gesamtzeile dafür in ein
tfoot verschoben. test_gui.py::test_gui_zeigt_deutsche_betragsformate
angepasst, da das neue machinenlesbare data-sort-Attribut bewusst
Punkt-Dezimal führt (Interface-Konvention) - die Prüfung auf deutsches
Format im sichtbaren Text bleibt bestehen.

JS-Unit-/DOM-Tests unter tests/js (node --test, DOM-Teil optional via
jsdom), pytest-Wrapper skip ohne node (Fable-Gate führt sie per
Podman aus). Suite: 248 passed, 3 skipped (vorher 242/2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:22:34 +02:00

304 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Planung – Finanzberatung{% endblock %}
{% block content %}
<h1>Planung</h1>
<section class="planning-section">
<h2>Wiederkehrende Posten</h2>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for r in recurring %}
<tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td>
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_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>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
hx-confirm="„{{ r.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="rec-edit-{{ r.id }}" hidden data-sort-follow>
<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 %}
<tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neuen wiederkehrenden Posten anlegen</legend>
<form hx-ext="json-form" hx-post="/api/recurring" 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="-49,99" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% 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></label>
<label>Start (optional) <input type="date" name="start_date"></label>
<label>Ende (optional) <input type="date" name="end_date"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">–</option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
<fieldset>
<legend>Vorschläge aus Buchungen</legend>
{% if suggestions %}
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th></th></tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}" data-sort="{{ s.amount }}">
{{ s.amount|eur }} €
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
</td>
<td>{{ s.rhythm|de_label }}</td>
<td>{{ s.due_day }}</td>
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="hidden" name="name" value="{{ s.name }}">
<input type="hidden" name="amount" value="{{ s.amount }}">
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">
<button type="submit">Vorschlag übernehmen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Keine Vorschläge.</p>
{% endif %}
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
</fieldset>
</section>
<section class="planning-section">
<h2>Einmalposten</h2>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for p in planned %}
<tr id="pln-row-{{ p.id }}">
<td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}" data-sort="{{ p.amount }}">{{ p.amount|eur }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '–') }}</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"
hx-confirm="„{{ p.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="pln-edit-{{ p.id }}" hidden data-sort-follow>
<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 %}
<tr><td colspan="5">Noch keine Einmalposten.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neuen Einmalposten anlegen</legend>
<form hx-ext="json-form" hx-post="/api/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="-2000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">–</option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<section class="planning-section">
<h2>Kredite</h2>
<table>
<thead>
<tr><th>Name</th><th>Summe</th><th>Zins p.a.</th><th>Laufzeit</th><th>Auszahlung</th><th>Tilgungsart</th><th></th></tr>
</thead>
<tbody>
{% for l in loans %}
<tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td>
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td>
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type|de_label }}</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"
hx-confirm="„{{ l.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
muessen betroffene Szenarien neu durchgerechnet werden. -->
<tr id="loan-edit-{{ l.id }}" hidden data-sort-follow>
<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 id="loan-plan-{{ l.id }}" data-sort-follow>
<td colspan="7">
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
<summary>Tilgungsplan</summary>
<div id="schedule-{{ l.id }}">Wird beim Öffnen geladen…</div>
</details>
</td>
</tr>
{% else %}
<tr><td colspan="7">Noch keine Kredite.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neuen Kredit anlegen</legend>
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<script>
// json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
// mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
var loadedSchedules = {};
function formatIsoDate(iso) {
var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
function fmtEur(n) {
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function loadLoanSchedule(loanId) {
if (loadedSchedules[loanId]) return;
loadedSchedules[loanId] = true;
fetch('/api/loans/' + loanId + '/schedule')
.then(function (r) { return r.json(); })
.then(function (rows) {
var html = '<table><thead><tr><th>Fällig</th><th>Rate</th><th>Zins</th>'
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
+ '<td>' + fmtEur(r.payment) + ' €</td>'
+ '<td>' + fmtEur(r.interest) + ' €</td>'
+ '<td>' + fmtEur(r.principal) + ' €</td>'
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
});
html += '</tbody></table>';
document.getElementById('schedule-' + loanId).innerHTML = html;
});
}
</script>
{% endblock %}