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:
@@ -10,7 +10,15 @@ from app.auth import COOKIE, session_valid
|
||||
from app.db import get_session
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.models.tables import (Account, Category, PlannedItem,
|
||||
ProjectionResult, RecurringItem)
|
||||
ProjectionResult, RecurringItem,
|
||||
ScenarioLoan, ScenarioModifier)
|
||||
from app.routers.imports import list_imports as _list_imports
|
||||
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_planned as _list_planned
|
||||
from app.routers.planning import list_recurring as _list_recurring
|
||||
from app.routers.planning import recurring_suggestions as _recurring_suggestions
|
||||
from app.routers.scenarios import list_scenarios as _list_scenarios
|
||||
from app.routers.transactions import list_transactions
|
||||
from app.services.balances import account_balance, total_balance
|
||||
|
||||
@@ -102,12 +110,70 @@ def buchungen(
|
||||
|
||||
|
||||
@router.get("/import", dependencies=[Depends(gui_session)])
|
||||
def import_page(request: Request):
|
||||
# Platzhalter, wird in Task 13 mit Upload-/Vorschau-Funktion gefüllt.
|
||||
return templates.TemplateResponse(request, "import.html", {})
|
||||
def import_page(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "import.html", {
|
||||
"statements": _list_imports(session=session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import/list", dependencies=[Depends(gui_session)])
|
||||
def import_list_fragment(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "_import_list.html", {
|
||||
"statements": _list_imports(session=session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import/{statement_id}/preview-fragment", dependencies=[Depends(gui_session)])
|
||||
def import_preview_fragment(statement_id: int, request: Request,
|
||||
session: Session = Depends(get_session)):
|
||||
result = _preview_import(statement_id, session=session)
|
||||
return templates.TemplateResponse(request, "_preview_table.html", {
|
||||
"statement": result.statement,
|
||||
"transactions": result.transactions,
|
||||
"balance_ok": result.balance_ok,
|
||||
"duplicates": result.duplicates,
|
||||
})
|
||||
|
||||
|
||||
def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]:
|
||||
"""Baut je Szenario den Kontext fürs Template: zugeordnete Kredite,
|
||||
Modifikatoren und (falls vorhanden) das letzte Projektionsergebnis."""
|
||||
rows = []
|
||||
for sc in scenarios:
|
||||
assigned_loan_ids = {
|
||||
sl.loan_id for sl in session.execute(
|
||||
select(ScenarioLoan).where(ScenarioLoan.scenario_id == sc.id)
|
||||
).scalars()
|
||||
}
|
||||
modifiers = session.execute(
|
||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||
).scalars().all()
|
||||
rows.append({
|
||||
"scenario": sc,
|
||||
"assigned_loan_ids": assigned_loan_ids,
|
||||
"modifiers": modifiers,
|
||||
"result": session.get(ProjectionResult, sc.id),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/planung", dependencies=[Depends(gui_session)])
|
||||
def planung_page(request: Request):
|
||||
# Platzhalter, wird in Task 13 mit Fixposten-/Szenario-Verwaltung gefüllt.
|
||||
return templates.TemplateResponse(request, "planung.html", {})
|
||||
def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
planned = _list_planned(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
scenarios = _list_scenarios(session=session)
|
||||
return templates.TemplateResponse(request, "planning.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
"recurring": recurring,
|
||||
"recurring_names": {r.id: r.name for r in recurring},
|
||||
"suggestions": _recurring_suggestions(session=session),
|
||||
"planned": planned,
|
||||
"loans": loans,
|
||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||
"repayment_types": ["annuity", "bullet"],
|
||||
"modifier_kinds": ["percent", "absolute", "remove"],
|
||||
})
|
||||
|
||||
@@ -92,3 +92,53 @@ iframe.grafana {
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #7a5c00;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #fdecea;
|
||||
color: #b00020;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: #e6f4ea;
|
||||
color: #1e7e34;
|
||||
}
|
||||
|
||||
#dropzone {
|
||||
border: 2px dashed #999;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#dropzone.dragover {
|
||||
background: #f0f6ff;
|
||||
border-color: #06c;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
section.planning-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section.planning-section fieldset {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
49
finance/app/templates/_import_list.html
Normal file
49
finance/app/templates/_import_list.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datei</th><th>Bank</th><th>Zeitraum</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in statements %}
|
||||
<tr>
|
||||
<td>{{ s.filename }}</td>
|
||||
<td>{{ s.bank }}</td>
|
||||
<td>
|
||||
{% if s.period_start and s.period_end %}
|
||||
{{ s.period_start.strftime('%d.%m.%Y') }} – {{ s.period_end.strftime('%d.%m.%Y') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.status == "draft" %}
|
||||
<span class="badge badge-warning">wartet auf Bestätigung</span>
|
||||
{% elif s.status == "error" %}
|
||||
<span class="badge badge-error">Fehler: {{ s.error_message }}</span>
|
||||
{% elif s.status == "confirmed" %}
|
||||
<span class="badge badge-ok">übernommen</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ s.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.status == "draft" %}
|
||||
<details hx-get="/import/{{ s.id }}/preview-fragment"
|
||||
hx-trigger="toggle once" hx-target="find .preview-slot" hx-swap="innerHTML">
|
||||
<summary>Vorschau</summary>
|
||||
<div class="preview-slot">Lädt…</div>
|
||||
</details>
|
||||
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/confirm" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Übernehmen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
|
||||
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Verwerfen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Noch keine Imports vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
29
finance/app/templates/_preview_table.html
Normal file
29
finance/app/templates/_preview_table.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th><th>Betrag</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ tx.purpose }}</td>
|
||||
<td>{{ tx.counterparty }}</td>
|
||||
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }} €</td>
|
||||
<td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Keine Buchungen in diesem Import.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Saldo-Status:
|
||||
{% if balance_ok %}
|
||||
<span class="badge badge-ok">Saldo stimmt</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">Saldo-Differenz – bitte prüfen</span>
|
||||
{% endif %}
|
||||
· Eröffnungssaldo: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '–' }} €
|
||||
· Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '–' }} €
|
||||
· Duplikate: {{ duplicates }}
|
||||
</p>
|
||||
@@ -2,5 +2,68 @@
|
||||
{% block title %}Import – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Import</h1>
|
||||
<p>Der Kontoauszug-Import (Upload, Vorschau, Bestätigen) folgt in einem späteren Ausbauschritt.</p>
|
||||
|
||||
<div id="dropzone">
|
||||
Kontoauszug-PDF hier ablegen oder klicken zum Hochladen
|
||||
<input type="file" id="file-input" accept="application/pdf" style="display:none">
|
||||
</div>
|
||||
<p id="upload-message"></p>
|
||||
|
||||
<h2>Importe</h2>
|
||||
<div id="imports" hx-get="/import/list" hx-trigger="every 5s, refresh" hx-swap="innerHTML">
|
||||
{% include "_import_list.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var dropzone = document.getElementById('dropzone');
|
||||
var fileInput = document.getElementById('file-input');
|
||||
var message = document.getElementById('upload-message');
|
||||
|
||||
function refreshImports() {
|
||||
htmx.trigger(document.getElementById('imports'), 'refresh');
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
if (!file) return;
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
message.textContent = 'Lade hoch: ' + file.name + ' …';
|
||||
fetch('/api/imports/upload', { method: 'POST', body: fd })
|
||||
.then(function (resp) {
|
||||
return resp.json().then(function (data) { return { ok: resp.ok, data: data }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result.ok) {
|
||||
message.textContent = 'Hochgeladen: ' + file.name + ' – Status: ' + result.data.status;
|
||||
} else {
|
||||
message.textContent = 'Fehler beim Hochladen: ' + (result.data.detail || 'unbekannter Fehler');
|
||||
}
|
||||
refreshImports();
|
||||
})
|
||||
.catch(function (err) {
|
||||
message.textContent = 'Fehler beim Hochladen: ' + err;
|
||||
});
|
||||
}
|
||||
|
||||
dropzone.addEventListener('click', function () { fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () {
|
||||
uploadFile(fileInput.files[0]);
|
||||
fileInput.value = '';
|
||||
});
|
||||
dropzone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
dropzone.addEventListener('dragleave', function () {
|
||||
dropzone.classList.remove('dragover');
|
||||
});
|
||||
dropzone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('dragover');
|
||||
var file = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
uploadFile(file);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
387
finance/app/templates/planning.html
Normal file
387
finance/app/templates/planning.html
Normal 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 %}
|
||||
@@ -1,6 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Planung – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Planung</h1>
|
||||
<p>Die Verwaltung von Fixposten, Einmalposten und Szenarien folgt in einem späteren Ausbauschritt.</p>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,35 @@ def test_pages_require_login(client):
|
||||
|
||||
def test_pages_render_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/buchungen"):
|
||||
for path in ("/", "/buchungen", "/import", "/planung"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "Finanzberatung" in r.text
|
||||
|
||||
|
||||
def test_import_page_has_dropzone_and_list(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/import")
|
||||
assert r.status_code == 200
|
||||
assert 'id="dropzone"' in r.text
|
||||
assert 'id="imports"' in r.text
|
||||
|
||||
|
||||
def test_import_list_fragment_requires_login(client):
|
||||
r = client.get("/import/list", follow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
|
||||
def test_import_list_fragment_renders_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/import/list")
|
||||
assert r.status_code == 200
|
||||
assert "Noch keine Imports" in r.text
|
||||
|
||||
|
||||
def test_planning_page_has_all_sections(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/planung")
|
||||
assert r.status_code == 200
|
||||
for heading in ("Wiederkehrende Posten", "Einmalposten", "Kredite", "Szenarien"):
|
||||
assert heading in r.text
|
||||
|
||||
Reference in New Issue
Block a user