Add SQLite passive income dashboard with Excel import and deployment tools

This commit is contained in:
kai
2026-09-09 08:46:27 +02:00
parent 729a13bfca
commit afc6f74f36
27 changed files with 1109 additions and 67 deletions
+9
View File
@@ -0,0 +1,9 @@
<div class="table-scroll"><table>
<thead><tr><th>Datum</th><th>Position</th><th>Kategorie</th><th class="numeric">Betrag</th><th>Status</th><th>Notiz</th><th>Aktionen</th></tr></thead>
<tbody>{% for entry in entries %}<tr>
<td class="nowrap">{{ entry.date[8:10] }}.{{ entry.date[5:7] }}.{{ entry.date[:4] }}</td><td>{{ entry.name }}</td><td>{{ categories[entry.category] }}</td>
<td class="numeric {{ 'negative' if entry.amount < 0 else '' }}">{{ entry.amount|money }}</td>
<td><span class="badge {{ 'positive' if entry.received else 'warning' }}">{{ 'Erhalten' if entry.received else ('Erwartet / offen' if entry.expected else 'Nicht erhalten') }}</span></td>
<td class="note">{{ entry.note or '' }}</td><td><div class="actions"><a href="/income/{{ entry.id }}/edit">Bearbeiten</a><form method="post" action="/income/{{ entry.id }}/delete" data-confirm="Diese Zahlung wirklich löschen?"><button class="danger small" type="submit">Löschen</button></form></div></td>
</tr>{% else %}<tr><td colspan="7" class="empty">Noch keine Zahlungen vorhanden. Trage eine Zahlung ein oder importiere deine Excel-Historie.</td></tr>{% endfor %}</tbody>
</table></div>
+3
View File
@@ -0,0 +1,3 @@
{% extends 'base.html' %}
{% block content %}<section class="panel form-panel"><h1>Neue Position</h1>{% if error %}<p class="notice negative" role="alert">{{ error }}</p>{% endif %}
<form method="post" class="entry-form"><label>Name<input name="name" required maxlength="150" value="{{ data.get('name', '') }}"></label><label>Ticker (optional)<input name="ticker" maxlength="30" value="{{ data.get('ticker', '') }}"></label><label>Positionstyp<select name="asset_type">{% for key,label in asset_types.items() %}<option value="{{ key }}" {{ 'selected' if data.get('asset_type') == key else '' }}>{{ label }}</option>{% endfor %}</select></label><p class="muted">Bekannte Namen werden zusammengeführt. MSC steht für Main Street Capital.</p><div class="actions"><button type="submit">Position speichern</button><a href="/income/new">Zurück</a></div></form></section>{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"><title>{% block title %}Finance Dashboard{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='css/style.css') }}">
<script defer src="{{ url_for('static', path='js/forms.js') }}"></script>
</head>
<body>
<header class="topbar"><a class="brand" href="/">💶 Finance Dashboard</a><nav aria-label="Hauptnavigation"><a href="/">Übersicht</a><a href="/income">Alle Zahlungen</a><a href="/export/income.csv">CSV-Export</a></nav></header>
<main>
{% set message = request.query_params.get('message') %}
{% if message in ['saved', 'deleted'] %}<p class="notice success" role="status">{{ 'Zahlung gespeichert.' if message == 'saved' else 'Zahlung gelöscht.' }}</p>{% endif %}
{% block content %}{% endblock %}
</main>
<footer>pinguAurora meldet sich zum Dienst. <span>Private Finanzen · Beträge in EUR</span></footer>
{% block scripts %}{% endblock %}
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block content %}<div class="page-heading"><div><p class="eyebrow">ERTRAGSBUCH</p><h1>Alle Zahlungen</h1></div><a class="button" href="/income/new">+ Zahlung eintragen</a></div>
<section class="panel"><form method="get" class="filters" id="income-filters">
<label>Jahr<select name="year"><option value="">Alle Jahre</option>{% for year in years %}<option value="{{ year }}" {{ 'selected' if filters.year == year else '' }}>{{ year }}</option>{% endfor %}</select></label>
<label>Monat<select name="month"><option value="">Alle Monate</option>{% for month in months %}<option value="{{ loop.index }}" {{ 'selected' if filters.month == loop.index else '' }}>{{ month }}</option>{% endfor %}</select></label>
<label>Position<select name="asset_id"><option value="">Alle Positionen</option>{% for asset in assets %}<option value="{{ asset.id }}" {{ 'selected' if filters.asset_id == asset.id else '' }}>{{ asset.name }}</option>{% endfor %}</select></label>
<label>Kategorie<select name="category"><option value="">Alle Kategorien</option>{% for key,label in categories.items() %}<option value="{{ key }}" {{ 'selected' if filters.category == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<button type="submit">Filtern</button><a href="/income">Zurücksetzen</a></form>
{% include '_entries.html' %}<div class="pagination">{% if page > 1 %}<a href="{{ request.url.include_query_params(page=page-1) }}">← Zurück</a>{% endif %}<span>Seite {{ page }} · bis zu 100 Zahlungen pro Seite</span>{% if more %}<a href="{{ request.url.include_query_params(page=page+1) }}">Weiter →</a>{% endif %}</div></section>{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}Zahlung {{ 'bearbeiten' if entry_id else 'eintragen' }} · Finance Dashboard{% endblock %}
{% block content %}
<section class="form-panel panel"><p class="eyebrow">ERTRAGSBUCH</p><h1>Zahlung {{ 'bearbeiten' if entry_id else 'eintragen' }}</h1>
{% if error %}<p role="alert" class="notice negative">{{ error }}</p>{% endif %}
<form method="post" class="entry-form">
<label>Datum<input required type="date" name="date" value="{{ data.get('date', '') }}"></label>
<label>Position<select name="asset_id" required><option value="">Bitte auswählen</option>{% for asset in assets %}{% if asset.active or asset.id|string == data.get('asset_id')|string %}<option value="{{ asset.id }}" {{ 'selected' if asset.id|string == data.get('asset_id')|string else '' }}>{{ asset.name }}{{ ' (inaktiv)' if not asset.active else '' }}</option>{% endif %}{% endfor %}</select></label>
<a href="/assets/new">+ Neue Position</a>
<label>Kategorie<select name="category" required>{% for key, label in categories.items() %}<option value="{{ key }}" {{ 'selected' if data.get('category') == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<label>Betrag in Euro<input required type="text" name="amount" inputmode="decimal" maxlength="14" placeholder="0,04" value="{{ data.get('amount', '') }}"><small>Komma oder Punkt, maximal zwei Nachkommastellen. Negative Beträge für Korrekturen.</small></label>
<label>Notiz (optional)<textarea name="note" rows="3" maxlength="2000">{{ data.get('note') or '' }}</textarea></label>
<label class="checkbox"><input type="checkbox" name="expected" value="1" {{ 'checked' if data.get('expected') else '' }}>Erwartete Zahlung</label>
<label class="checkbox"><input type="checkbox" name="received" value="1" {{ 'checked' if data.get('received') else '' }}>Tatsächlich erhalten</label>
<p class="muted">Nur „Tatsächlich erhalten“ fließt in die Auswertung ein. Bei offenen oder ausgefallenen Zahlungen dieses Häkchen entfernen.</p>
<div class="actions"><button type="submit">Zahlung speichern</button><a href="/">Abbrechen</a></div>
</form></section>
{% endblock %}
+28 -57
View File
@@ -1,57 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Finance Dashboard</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: 24px;
background: #0c1220;
color: #e8edf5;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.6;
}
.card {
width: 100%;
max-width: 620px;
padding: clamp(24px, 6vw, 48px);
border: 1px solid #2a3549;
border-radius: 20px;
background: #151e2e;
box-shadow: 0 20px 60px #00000040;
}
h1 {
margin: 0 0 24px;
font-size: clamp(1.5rem, 5vw, 2.25rem);
line-height: 1.25;
letter-spacing: -0.03em;
}
.status {
margin: 0 0 16px;
color: #86efac;
font-weight: 600;
}
.description { margin: 0; color: #b7c3d5; }
</style>
</head>
<body>
<main class="card">
<h1>💶 Finance Dashboard</h1>
<p class="status">pinguAurora meldet sich zum Dienst.</p>
<p class="description">Hier entsteht dein persönliches Dashboard für Dividenden, Zinsen, Depot und Notgroschen.</p>
</main>
</body>
</html>
{% extends 'base.html' %}
{% macro delta(value) %}<span class="{{ 'positive' if value is not none and value > 0 else 'negative' if value is not none and value < 0 else 'muted' }}">{{ value|percent }}</span>{% endmacro %}
{% block content %}
<section class="page-heading"><div><p class="eyebrow">PASSIVES EINKOMMEN</p><h1>Deine Erträge im Überblick</h1><p class="muted">Dividenden, Ausschüttungen und Zinsen · Stand {{ stats.today.strftime('%d.%m.%Y') }}</p></div><a class="button" href="/income/new">+ Zahlung eintragen</a></section>
<div class="kpi-grid">
<article class="kpi"><h2>{{ months[stats.today.month-1] }} {{ stats.today.year }}</h2><strong>{{ stats.month|money }}</strong><small>Passives Einkommen aktueller Monat</small></article>
<article class="kpi"><h2>Gleicher Monat Vorjahr</h2><strong>{{ stats.prior_month|money }}</strong><small>{{ months[stats.today.month-1] }} {{ stats.today.year-1 }}</small></article>
<article class="kpi"><h2>Monat zum Vorjahr</h2><strong>{{ delta(stats.month_change) }}</strong><small>Veränderung zum Vorjahresmonat</small></article>
<article class="kpi"><h2>Aktuelles Jahr {{ stats.today.year }}</h2><strong>{{ stats.year|money }}</strong><small>Tatsächlich erhaltene Zahlungen</small></article>
<article class="kpi"><h2>Vorjahr {{ stats.today.year-1 }}</h2><strong>{{ stats.prior_year|money }}</strong><small>Gesamtes Kalenderjahr</small></article>
<article class="kpi"><h2>Jahr zum Vorjahr</h2><strong>{{ delta(stats.year_change) }}</strong><small>Aktuelles Jahr gegen gesamtes Vorjahr</small></article>
<article class="kpi"><h2>Alle Jahre</h2><strong class="positive">{{ stats.all_time|money }}</strong><small>Passives Einkommen insgesamt</small></article>
<article class="kpi"><h2>Zahlungen {{ stats.today.year }}</h2><strong>{{ stats.count }}</strong><small>Anzahl tatsächlich erhaltener Zahlungen</small></article>
</div>
{% if stats.pending.count %}<aside class="notice warning">{{ stats.pending.count }} erwartete, nicht erhaltene Zahlungen: <strong>{{ stats.pending.amount|money }}</strong>. Diese Beträge sind nicht in den tatsächlichen Einnahmen enthalten. <a href="/income">Buchungen ansehen</a></aside>{% endif %}
<section class="panel"><div class="section-heading"><h2>Jahresvergleich</h2><span class="muted">Nur tatsächlich erhaltene Zahlungen</span></div>
<div class="table-scroll"><table class="comparison"><thead><tr><th>Monat</th>{% for year in stats.years %}<th>{{ year }}</th>{% if not loop.first %}<th>{{ year }} vs. {{ year-1 }}</th>{% endif %}{% endfor %}<th>Alle Jahre</th></tr></thead>
<tbody>{% for month in months %}{% set mi = loop.index0 %}<tr><th>{{ month }}</th>{% for year in stats.years %}<td>{{ stats.monthly[year][mi]|money }}</td>{% if not loop.first %}<td>{{ delta(compare(stats.monthly[year][mi], stats.monthly[year-1][mi])) }}</td>{% endif %}{% endfor %}<td>{{ stats.monthly.values()|map(attribute=mi)|sum|money }}</td></tr>{% endfor %}</tbody>
<tfoot><tr><th>Gesamt</th>{% for year in stats.years %}<td>{{ stats.totals[year]|money }}</td>{% if not loop.first %}<td>{{ delta(compare(stats.totals[year], stats.totals[year-1])) }}</td>{% endif %}{% endfor %}<td>{{ stats.all_time|money }}</td></tr></tfoot></table></div></section>
<div class="chart-grid">
<section class="panel chart-wide"><h2>Monatsverlauf</h2><p class="muted">Die Jahre im direkten Vergleich</p><div class="chart"><canvas id="monthly-chart" role="img" aria-label="Monatseinnahmen je Jahr; Zahlen in der Jahresvergleichstabelle"></canvas></div></section>
<section class="panel"><h2>Einkommensanteile</h2><p class="muted">Positionen · alle Jahre</p><div class="chart"><canvas id="shares-chart" role="img" aria-label="Anteile nach Position"></canvas></div><details><summary>Beträge nach Position</summary><ul class="amount-list">{% for row in stats.shares %}<li><span>{{ row.name }}</span><strong>{{ row.amount|money }}</strong></li>{% else %}<li>Noch keine Einnahmen.</li>{% endfor %}</ul></details></section>
<section class="panel"><h2>Einkommensarten</h2><p class="muted">Alle Jahre</p><div class="chart"><canvas id="kinds-chart" role="img" aria-label="Anteile nach Einkommensart"></canvas></div><details><summary>Beträge nach Einkommensart</summary><ul class="amount-list">{% for row in stats.chart.kinds %}<li><span>{{ row.name }}</span><strong>{{ row.amount|money }}</strong></li>{% endfor %}</ul></details></section>
</div>
<p class="muted" id="chart-status" role="status">Diagramme werden geladen. Alle Beträge sind auch als Tabellen verfügbar.</p>
<section class="panel"><div class="section-heading"><h2>Letzte Zahlungen</h2><a href="/income">Alle Zahlungen →</a></div>{% include '_entries.html' %}</section>
{% endblock %}
{% block scripts %}<script id="chart-data" type="application/json">{{ stats.chart|tojson }}</script><script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script><script defer src="{{ url_for('static', path='js/dashboard.js') }}"></script>{% endblock %}