feat: Web-GUI Basis, Uebersicht, Buchungsliste

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:45:54 +02:00
parent e8ea948b38
commit a6009c47e3
11 changed files with 488 additions and 9 deletions

View File

@@ -1,15 +1,20 @@
import hmac
from fastapi import FastAPI, Form
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from app import auth
from app.config import get_settings
from app.routers import (accounts, categories, imports, planning, scenarios,
transactions)
from app.routers import (accounts, categories, gui, imports, planning,
scenarios, transactions)
from app.routers.gui import templates
app = FastAPI(title="Finanzberatungs-Tool")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(gui.router)
app.include_router(accounts.router)
app.include_router(transactions.router)
app.include_router(categories.router)
@@ -18,12 +23,9 @@ app.include_router(planning.router)
app.include_router(scenarios.router)
@app.get("/login", response_class=HTMLResponse)
def login_form():
return """<form method=post action=/login>
<input name=username placeholder=Benutzer>
<input name=password type=password placeholder=Passwort>
<button>Anmelden</button></form>"""
@app.get("/login")
def login_form(request: Request):
return templates.TemplateResponse(request, "login.html", {})
@app.post("/login")

111
finance/app/routers/gui.py Normal file
View File

@@ -0,0 +1,111 @@
from datetime import date, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
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)
from app.routers.transactions import list_transactions
from app.services.balances import account_balance, total_balance
templates = Jinja2Templates(directory="app/templates")
router = APIRouter()
def gui_session(request: Request):
if not session_valid(request.cookies.get(COOKIE)):
raise _redirect()
def _redirect():
exc = HTTPException(302)
exc.headers = {"Location": "/login"}
return exc
def _upcoming_items(session: Session, today: date, days: int = 30, limit: int = 10) -> list[dict]:
"""Nächste Posten aus RecurringItems (Termine) und PlannedItems der nächsten `days` Tage."""
end = today + timedelta(days=days)
items: list[dict] = []
for r in session.execute(select(RecurringItem)).scalars():
for d in occurrences(r.rhythm, r.due_day, today, end, r.start_date, r.end_date):
items.append({"date": d, "name": r.name, "amount": Decimal(r.amount)})
for p in session.execute(
select(PlannedItem).where(PlannedItem.due >= today, PlannedItem.due <= end)
).scalars():
items.append({"date": p.due, "name": p.name, "amount": Decimal(p.amount)})
items.sort(key=lambda i: i["date"])
return items[:limit]
def _latest_projection(session: Session) -> ProjectionResult | None:
return session.execute(
select(ProjectionResult).order_by(ProjectionResult.computed_at.desc())
).scalars().first()
@router.get("/", dependencies=[Depends(gui_session)])
def index(request: Request, session: Session = Depends(get_session)):
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a)) for a in accounts]
return templates.TemplateResponse(request, "index.html", {
"balances": balances,
"total": total_balance(session),
"upcoming": _upcoming_items(session, date.today()),
"projection": _latest_projection(session),
})
@router.get("/buchungen", dependencies=[Depends(gui_session)])
def buchungen(
request: Request,
account_id: int | None = None,
date_from: date | None = None,
date_to: date | None = None,
category_id: int | None = None,
q: str | None = None,
status: str = "confirmed",
session: Session = Depends(get_session),
):
txs = list_transactions(
account_id=account_id,
date_from=date_from,
date_to=date_to,
category_id=category_id,
q=q,
status=status,
limit=200,
offset=0,
session=session,
)
return templates.TemplateResponse(request, "transactions.html", {
"transactions": txs,
"accounts": session.execute(select(Account)).scalars().all(),
"categories": session.execute(select(Category)).scalars().all(),
"filters": {
"account_id": account_id,
"date_from": date_from,
"date_to": date_to,
"category_id": category_id,
"q": q or "",
"status": status,
},
})
@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", {})
@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", {})

1
finance/app/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,94 @@
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 0 auto;
padding: 1rem;
color: #222;
background: #fff;
}
nav {
display: flex;
gap: 1rem;
align-items: center;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
nav a {
text-decoration: none;
color: #06c;
}
nav form {
margin-left: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1.5rem;
}
th, td {
border: 1px solid #ddd;
padding: 0.4rem 0.6rem;
text-align: left;
}
th {
background: #f2f2f2;
}
tr:nth-child(even) {
background: #fafafa;
}
.neg {
color: #b00;
}
.warning {
background: #fff3cd;
border: 1px solid #e0c26b;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.total-balance {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
}
fieldset {
margin-bottom: 1.5rem;
}
form.filter-form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: end;
margin-bottom: 1rem;
}
form.filter-form label {
display: flex;
flex-direction: column;
font-size: 0.85rem;
}
iframe.grafana {
width: 100%;
height: 480px;
border: 1px solid #ccc;
}
button {
cursor: pointer;
}

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Finanzberatung{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<nav>
<a href="/">Übersicht</a>
<a href="/import">Import</a>
<a href="/buchungen">Buchungen</a>
<a href="/planung">Planung</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
<form method="post" action="/logout">
<button type="submit">Logout</button>
</form>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% 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>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Übersicht Finanzberatung{% endblock %}
{% block content %}
<h1>Übersicht</h1>
{% if projection and projection.below_threshold_date %}
<div class="warning">
Achtung: Prognostizierter Saldo unterschreitet den Schwellenwert am
<strong>{{ projection.below_threshold_date.strftime('%d.%m.%Y') }}</strong>.
</div>
{% endif %}
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
Gesamtsaldo: {{ '%.2f'|format(total) }} €
</div>
<table>
<thead>
<tr><th>Konto</th><th>Bank</th><th>Saldo</th></tr>
</thead>
<tbody>
{% for account, balance in balances %}
<tr>
<td>{{ account.name }}</td>
<td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
</tr>
{% else %}
<tr><td colspan="3">Keine Konten angelegt.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Nächste anstehende Posten (30 Tage)</h2>
<table>
<thead>
<tr><th>Datum</th><th>Bezeichnung</th><th>Betrag</th></tr>
</thead>
<tbody>
{% for item in upcoming %}
<tr>
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
<td>{{ item.name }}</td>
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ '%.2f'|format(item.amount) }} €</td>
</tr>
{% else %}
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Grafana-Dashboard</h2>
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097"></iframe>
{% endblock %}

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden Finanzberatung</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>Finanzberatung</h1>
<form method="post" action="/login">
<label>Benutzer
<input name="username" placeholder="Benutzer">
</label>
<label>Passwort
<input name="password" type="password" placeholder="Passwort">
</label>
<button type="submit">Anmelden</button>
</form>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{% 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 %}

View File

@@ -0,0 +1,146 @@
{% extends "base.html" %}
{% block title %}Buchungen Finanzberatung{% endblock %}
{% block content %}
<h1>Buchungen</h1>
<form class="filter-form" method="get" action="/buchungen">
<label>Konto
<select name="account_id">
<option value="">alle</option>
{% for a in accounts %}
<option value="{{ a.id }}" {% if filters.account_id == a.id %}selected{% endif %}>{{ a.name }}</option>
{% endfor %}
</select>
</label>
<label>Von
<input type="date" name="date_from" value="{{ filters.date_from.isoformat() if filters.date_from else '' }}">
</label>
<label>Bis
<input type="date" name="date_to" value="{{ filters.date_to.isoformat() if filters.date_to else '' }}">
</label>
<label>Kategorie
<select name="category_id">
<option value="">alle</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if filters.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
<label>Suche
<input type="text" name="q" value="{{ filters.q }}" placeholder="Verwendungszweck / Empfänger">
</label>
<label>Status
<select name="status">
{% for s in ("confirmed", "draft") %}
<option value="{{ s }}" {% if filters.status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Filtern</button>
</form>
<table>
<thead>
<tr>
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
<th>Betrag</th><th>Kategorie</th><th></th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
<td>{{ tx.account_id }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }} €</td>
<td>
<select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
<option value=""></option>
{% for c in categories %}
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</td>
<td>
<details>
<summary>Regel erzeugen</summary>
<form hx-ext="json-form" hx-post="/api/category-rules" hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.closest('details').open=false}">
<input type="hidden" name="pattern" value="{{ tx.counterparty }}">
<label>Kategorie
<select name="category_id" data-type="int" required>
{% for c in categories %}
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Regel speichern</button>
</form>
</details>
</td>
</tr>
{% else %}
<tr><td colspan="7">Keine Buchungen gefunden.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Buchung manuell erfassen</legend>
<form hx-ext="json-form" hx-post="/api/transactions" hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.reset(); window.location.reload()}">
<label>Konto
<select name="account_id" data-type="int" required>
{% for a in accounts %}
<option value="{{ a.id }}">{{ a.name }}</option>
{% endfor %}
</select>
</label>
<label>Buchungsdatum
<input type="date" name="booking_date" required>
</label>
<label>Betrag
<input type="text" name="amount" placeholder="-12.50" required>
</label>
<label>Verwendungszweck
<input type="text" name="purpose">
</label>
<label>Empfänger/Zahler
<input type="text" name="counterparty">
</label>
<button type="submit">Buchung speichern</button>
</form>
</fieldset>
<script>
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
// kodiert, leere Werte als null, alles andere bleibt String.
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.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;
}
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
});
</script>
{% endblock %}

13
finance/tests/test_gui.py Normal file
View File

@@ -0,0 +1,13 @@
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/planung"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen"):
r = client.get(path)
assert r.status_code == 200
assert "Finanzberatung" in r.text