feat: Import-Seite mit Drag-and-Drop und Planungs-Seite

Ersetzt die Platzhalter-Routen aus Task 12 durch echte GUI-Seiten: /import
mit Drop-Zone (Fetch-Upload), gepollter Importliste (Status-Badges,
aufklappbarer Vorschau samt Saldo-/Duplikat-Anzeige, Uebernehmen/Verwerfen)
und /planung mit den vier Abschnitten Fixposten (inkl. Vorschlaege),
Einmalposten, Kredite (Tilgungsplan-Aufklapper) und Szenarien
(Kredit-Zuordnung, Modifikatoren, Durchrechnen mit Ergebnisanzeige).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:05:58 +02:00
parent 3847398055
commit afbbcbe9a5
8 changed files with 681 additions and 15 deletions

View File

@@ -0,0 +1,387 @@
{% 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>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for r in recurring %}
<tr>
<td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ '%.2f'|format(r.amount) }} €</td>
<td>{{ r.rhythm }}</td>
<td>{{ r.due_day }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td>
<td>
<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>
{% else %}
<tr><td colspan="6">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" placeholder="-49.99" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" 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>
{% if suggestions %}
<fieldset>
<legend>Vorschläge aus Buchungen</legend>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ '%.2f'|format(s.amount) }} €</td>
<td>{{ s.rhythm }}</td>
<td>{{ s.due_day }}</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 '' }}">
<button type="submit">Vorschlag übernehmen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</fieldset>
{% endif %}
</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>
<td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ '%.2f'|format(p.amount) }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td>
<td>
<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>
{% 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" 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>
<td>{{ l.name }}</td>
<td>{{ '%.2f'|format(l.principal) }} €</td>
<td>{{ '%.2f'|format(l.annual_rate_pct) }} %</td>
<td>{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type }}</td>
<td>
<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>
<tr>
<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" placeholder="10000.00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" 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 }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<section class="planning-section">
<h2>Szenarien</h2>
{% for row in scenario_rows %}
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p>
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
</p>
<details>
<summary>Kredite zuordnen</summary>
{% for l in loans %}
<label class="inline-form">
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked)">
{{ l.name }}
</label>
{% else %}
<p>Keine Kredite vorhanden.</p>
{% endfor %}
</details>
<details>
<summary>Modifikatoren</summary>
<table>
<thead>
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr>
<td>
{% 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 %}
</td>
<td>{{ m.kind }}</td>
<td>{{ '%.2f'|format(m.value) }}</td>
<td>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
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 Modifikatoren.</td></tr>
{% endfor %}
</tbody>
</table>
<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()}">
<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>
</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">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" value="0"></label>
<button type="submit">Modifikator hinzufügen</button>
</form>
</details>
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
{% if row.result %}
<p>
Tiefpunkt: {{ '%.2f'|format(row.result.low_point_balance) }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
</p>
{% else %}
<p>Noch nicht durchgerechnet.</p>
{% endif %}
</fieldset>
{% else %}
<p>Noch keine Szenarien.</p>
{% endfor %}
<fieldset>
<legend>Neues Szenario anlegen</legend>
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Beschreibung <input type="text" name="description"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
<button type="submit">Szenario anlegen</button>
</form>
</fieldset>
</section>
<script>
htmx.defineExtension('json-form', {
onEvent: function (name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json';
}
},
encodeParameters: function (xhr, parameters, elt) {
var out = {};
for (var k in parameters) {
var v = parameters[k];
var field = elt.matches && elt.matches('[name="' + k + '"]')
? elt
: elt.querySelector('[name="' + k + '"]');
var kind = field && field.dataset ? field.dataset.type : null;
if (v === '') {
out[k] = null;
} else if (kind === 'int') {
out[k] = parseInt(v, 10);
} else {
out[k] = v;
}
}
// Checkboxen mit data-type="bool" werden unabhaengig vom parameters-Objekt
// ausgelesen, weil unmarkierte Checkboxen beim Serialisieren komplett
// fehlen wuerden (kein Feld => kein "false" moeglich).
if (elt.querySelectorAll) {
elt.querySelectorAll('[data-type="bool"]').forEach(function (field) {
out[field.name] = field.checked;
});
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
});
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;
recSelect.disabled = isCategory;
}
function toggleScenarioLoan(scenarioId, loanId, checked) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function () { window.location.reload(); });
}
var loadedSchedules = {};
function formatIsoDate(iso) {
var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
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>' + Number(r.payment).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.interest).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.principal).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.remaining).toFixed(2) + ' €</td></tr>';
});
html += '</tbody></table>';
document.getElementById('schedule-' + loanId).innerHTML = html;
});
}
</script>
{% endblock %}