From 0d31d04275c5a0f60b701676bfcc881a11399064 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:11:43 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Ausgaben-Seite=20=E2=80=94=20visuell?= =?UTF-8?q?es=20Redesign=20aller=203=20Tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Übersicht: Hero-Card mit Gradient statt grauer Zeile, Vormonat-Trendpfeil (+/-%) - Kacheln: Icon oben, Betrag in primary-Farbe, Label klein darunter - Einträge: farbiges Icon-Badge (--kat-color), kompakter Monat-Trennstreifen mit Summe, Betrag fett rechts, Löschen-Icon direkt am Eintrag ohne Modal-Umweg - Statistik: gestapelte Top-2-Kategorien-Balken pro Monat (CSS-only), Donut-Diagramm via CSS conic-gradient, Kategorie-Legende - CSS: 435 neue Zeilen (exp-*) in components.css angehängt, keine bestehenden geändert --- backend/static/css/components.css | 435 ++++++++++++++++++++++++++++ backend/static/js/pages/expenses.js | 182 +++++++++--- 2 files changed, 583 insertions(+), 34 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 0acff7a..1930060 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6947,3 +6947,438 @@ svg.empty-state-icon { color: var(--c-text); line-height: 1.5; } + +/* ============================================================ + Ausgaben-Tracker (expenses.js) + ============================================================ */ + +/* FAB */ +.exp-fab { + position: fixed; + bottom: calc(var(--nav-height, 64px) + var(--space-4)); + right: var(--space-4); + z-index: 100; + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 14px rgba(0,0,0,.25); + cursor: pointer; + font-size: 1.35rem; + transition: transform .15s, box-shadow .15s; +} +.exp-fab:active { + transform: scale(.93); + box-shadow: 0 2px 8px rgba(0,0,0,.2); +} + +/* Lade-/Fehler-Zustände */ +.exp-loading { padding: var(--space-4); } +.exp-error { + padding: var(--space-4); + color: var(--c-danger); + font-size: var(--text-sm); + text-align: center; +} +.exp-empty-hint { + color: var(--c-text-secondary); + font-size: var(--text-sm); + padding: var(--space-3) 0; + text-align: center; +} + +/* ---- Hero-Card (Übersicht & Statistik oben) ---- */ +.exp-hero-card { + background: linear-gradient(135deg, var(--c-primary) 0%, color-mix(in srgb, var(--c-primary) 75%, #000) 100%); + color: #fff; + border-radius: var(--radius-xl, 16px); + padding: var(--space-5) var(--space-4); + margin: var(--space-3) var(--space-3) var(--space-4); + text-align: center; + box-shadow: 0 6px 20px rgba(0,0,0,.15); +} +.exp-hero-card--sm { + padding: var(--space-4) var(--space-4); +} +.exp-hero-label { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + opacity: .85; + margin-bottom: var(--space-1); + text-transform: uppercase; + letter-spacing: .04em; +} +.exp-hero-betrag { + font-size: clamp(1.9rem, 7vw, 2.8rem); + font-weight: var(--weight-bold); + line-height: 1.1; + letter-spacing: -.02em; +} +.exp-hero-meta { + margin-top: var(--space-2); + font-size: var(--text-sm); + opacity: .85; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* Trend-Badge */ +.exp-trend { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + padding: 2px 8px; + border-radius: 999px; +} +.exp-trend--up { background: rgba(239,68,68,.25); } +.exp-trend--down { background: rgba(16,185,129,.25); } + +/* ---- Kachel-Grid (Übersicht) ---- */ +.exp-kachel-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); + padding: 0 var(--space-3) var(--space-3); +} +.exp-kachel { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-2); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} +.exp-kachel-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + margin-bottom: var(--space-1); +} +.exp-kachel-betrag { + font-size: var(--text-sm); + font-weight: var(--weight-bold); + line-height: 1.1; +} +.exp-kachel-label { + font-size: var(--text-xs); + color: var(--c-text-secondary); + line-height: 1.2; +} + +/* ---- Sektion-Block (Verlauf etc.) ---- */ +.exp-section { + margin: 0 var(--space-3) var(--space-4); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} +.exp-section-title { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + margin-bottom: var(--space-3); + display: flex; + align-items: center; + gap: var(--space-1); + text-transform: uppercase; + letter-spacing: .04em; +} + +/* ---- Balkendiagramm (Verlauf) ---- */ +.exp-bar-chart { + display: flex; + align-items: flex-end; + gap: var(--space-1); + height: 80px; +} +.exp-bar-chart--12 { + height: 90px; + gap: 4px; +} +.exp-bar-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; +} +.exp-bar-item--aktiv .exp-bar-label { + color: var(--c-primary); + font-weight: var(--weight-semibold); +} +.exp-bar-track { + width: 100%; + height: 60px; + background: var(--c-surface-2, #f3f4f6); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow: hidden; +} +.exp-bar-track--stack { + height: 70px; +} +.exp-bar-fill { + width: 100%; + background: var(--c-primary); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + transition: height .4s ease; +} +.exp-bar-fill--aktiv { background: var(--c-primary); } +.exp-stack-seg { + width: 100%; + min-height: 2px; + transition: height .4s ease; +} +.exp-bar-label { + font-size: var(--text-xs); + color: var(--c-text-muted, #9ca3af); + white-space: nowrap; +} +.exp-bar-val { + font-size: var(--text-xs); + color: var(--c-text-secondary); +} + +/* ---- Einträge-Liste ---- */ +.exp-list { + padding: 0 var(--space-3); +} +.exp-month-group { + margin-bottom: var(--space-3); +} +.exp-month-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + background: var(--c-surface-2, #f3f4f6); + border-radius: var(--radius-md); + margin-bottom: var(--space-2); +} +.exp-month-title { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: .04em; +} +.exp-month-summe { + font-size: var(--text-sm); + font-weight: var(--weight-bold); + color: var(--c-primary); +} + +/* Einzelner Eintrag */ +.exp-entry { + display: flex; + align-items: center; + gap: var(--space-3); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + padding: var(--space-3); + margin-bottom: var(--space-2); + cursor: pointer; + transition: background .15s; +} +.exp-entry:active { background: var(--c-surface-2, #f3f4f6); } + +/* Icon-Badge mit Kategorie-Farbe */ +.exp-entry-icon-badge { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--kat-color) 15%, transparent); + color: var(--kat-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.exp-entry-body { + flex: 1; + min-width: 0; +} +.exp-entry-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: 2px; +} +.exp-entry-datum { + font-size: var(--text-xs); + color: var(--c-text-muted, #9ca3af); + flex-shrink: 0; +} +.exp-entry-kat { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text); +} +.exp-entry-notiz { + display: block; + font-size: var(--text-xs); + color: var(--c-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.exp-dog-badge { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: var(--text-xs); + color: var(--c-text-secondary); + background: var(--c-surface-2, #f3f4f6); + border-radius: 999px; + padding: 1px 6px; +} + +/* Rechte Spalte: Betrag + Löschen-Icon */ +.exp-entry-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-1); + flex-shrink: 0; +} +.exp-entry-betrag { + font-size: var(--text-base); + font-weight: var(--weight-bold); + color: var(--c-text); + white-space: nowrap; +} +.exp-entry-del { + background: transparent; + border: none; + color: var(--c-text-muted, #9ca3af); + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + font-size: 1rem; + line-height: 1; + transition: color .15s; +} +.exp-entry-del:hover { color: var(--c-danger); } + +/* ---- Statistik: Kategorie-Balken-Reihen ---- */ +.exp-stat-rows { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.exp-stat-row { + display: grid; + grid-template-columns: 120px 1fr 36px 80px; + align-items: center; + gap: var(--space-2); +} +.exp-stat-label { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-sm); + color: var(--c-text); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.exp-stat-icon { flex-shrink: 0; } +.exp-stat-bar-wrap { + height: 8px; + background: var(--c-surface-2, #f3f4f6); + border-radius: 999px; + overflow: hidden; +} +.exp-stat-bar { + height: 8px; + border-radius: 999px; + transition: width .5s ease; +} +.exp-stat-pct { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-align: right; +} +.exp-stat-val { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text); + text-align: right; + white-space: nowrap; +} + +/* ---- Donut-Diagramm (CSS conic-gradient) ---- */ +.exp-donut-wrap { + display: flex; + align-items: center; + gap: var(--space-5); + flex-wrap: wrap; +} +.exp-donut { + position: relative; + width: 120px; + height: 120px; + border-radius: 50%; + flex-shrink: 0; +} +.exp-donut-hole { + position: absolute; + inset: 28px; + background: var(--c-surface); + border-radius: 50%; +} +.exp-donut-legend { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 130px; +} +.exp-donut-legend-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); +} +.exp-donut-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.exp-donut-legend-label { + flex: 1; + color: var(--c-text); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.exp-donut-legend-pct { + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); +} diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js index c37d19e..e7c2b61 100644 --- a/backend/static/js/pages/expenses.js +++ b/backend/static/js/pages/expenses.js @@ -12,7 +12,6 @@ window.Page_expenses = (() => { // Cache let _summary = null; let _entries = []; - // Monats-Statistik-Daten (pro Monat und Kategorie) let _statsData = null; const TABS = [ @@ -114,6 +113,10 @@ window.Page_expenses = (() => { } const s = _summary; + // Vormonatsvergleich berechnen + const letzteMonat = await _getLetzteMonateData(); + const trendHtml = _trendHtml(letzteMonat); + const kacheln = KATEGORIEN.map(k => { const betrag = s.monat[k.id] || 0; return ` @@ -121,38 +124,35 @@ window.Page_expenses = (() => {
${UI.icon(k.icon)}
+
${_fmt(betrag)}
${k.label}
-
${_fmt(betrag)}
`; }).join(''); - const letzteMonat = await _getLetzteMonateData(); - const vergleich = letzteMonat.length > 1 - ? _vergleichHtml(letzteMonat) - : ''; + const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : ''; el.innerHTML = ` -
-
Dieser Monat
-
${_fmt(s.gesamt_monat)}
-
- ${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)} +
+
Dieser Monat
+
${_fmt(s.gesamt_monat)}
+
+ ${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)} + ${trendHtml}
${kacheln}
- ${vergleich} + ${verlauf}
`; } async function _getLetzteMonateData() { - // Letzten 6 Monate aus den Einträgen berechnen if (!_entries.length) { _entries = await API.get('/expenses?limit=500'); } const monatMap = {}; _entries.forEach(e => { - const m = e.datum.substring(0, 7); // YYYY-MM + const m = e.datum.substring(0, 7); monatMap[m] = (monatMap[m] || 0) + e.betrag; }); return Object.entries(monatMap) @@ -161,6 +161,21 @@ window.Page_expenses = (() => { .reverse(); } + function _trendHtml(data) { + // Vergleich: aktueller Monat vs. Vormonat + if (data.length < 2) return ''; + const aktuell = data[data.length - 1][1]; + const vormonat = data[data.length - 2][1]; + if (!vormonat) return ''; + const diff = aktuell - vormonat; + const pct = Math.round(Math.abs(diff / vormonat) * 100); + if (pct === 0) return ''; + const pfeil = diff > 0 + ? `${UI.icon('arrow-up')} +${pct}% ggü. Vormonat` + : `${UI.icon('arrow-down')} −${pct}% ggü. Vormonat`; + return pfeil; + } + function _vergleichHtml(data) { if (!data.length) return ''; const max = Math.max(...data.map(d => d[1]), 1); @@ -227,22 +242,28 @@ window.Page_expenses = (() => { ? `${UI.icon('paw-print')} ${_esc(e.dog_name)}` : ''; const notiz = e.notiz - ? `${_esc(e.notiz)}` + ? `${_esc(e.notiz)}` : ''; return `
-
+
${UI.icon(k.icon)}
+ ${datum} ${k.label} ${dogBadge} - ${datum}
${notiz}
-
${_fmt(e.betrag)}
+
+
${_fmt(e.betrag)}
+ +
`; }).join(''); @@ -258,13 +279,32 @@ window.Page_expenses = (() => { el.innerHTML = `
${html}
`; + // Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button) el.querySelectorAll('.exp-entry').forEach(row => { - row.addEventListener('click', () => { + row.addEventListener('click', (ev) => { + if (ev.target.closest('.exp-entry-del')) return; const id = parseInt(row.dataset.id); const entry = _entries.find(e => e.id === id); if (entry) _showForm(entry); }); }); + + // Löschen-Buttons + el.querySelectorAll('.exp-entry-del').forEach(btn => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const id = parseInt(btn.dataset.del); + if (!window.confirm('Diesen Eintrag wirklich löschen?')) return; + try { + await API.del(`/expenses/${id}`); + UI.toast.success('Ausgabe gelöscht.'); + _invalidateCache(); + await _renderTab(); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Löschen.'); + } + }); + }); } // ---------------------------------------------------------- @@ -281,7 +321,7 @@ window.Page_expenses = (() => { const s = _summary; const gesamtJahr = s.gesamt_jahr || 1; - // Jahres-Aufteilung nach Kategorien + // Jahres-Aufteilung nach Kategorien (als Balken-Reihen) const katBalken = KATEGORIEN .filter(k => (s.jahr[k.id] || 0) > 0) .sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0)) @@ -291,7 +331,7 @@ window.Page_expenses = (() => { return `
- ${UI.icon(k.icon)} + ${UI.icon(k.icon)} ${k.label}
@@ -302,38 +342,71 @@ window.Page_expenses = (() => {
`; }).join(''); - // Monats-Balken (aktuelles Jahr, Monat für Monat) + // Monats-Balken mit gestapelten Top-2-Kategorien const heute = new Date(); const jahrStr = heute.getFullYear().toString(); - const monatMap = {}; + + // Pro Monat: Summe je Kategorie berechnen + const monatKatMap = {}; // { monat: { katId: summe } } _entries .filter(e => e.datum.startsWith(jahrStr)) .forEach(e => { const m = parseInt(e.datum.split('-')[1]); - monatMap[m] = (monatMap[m] || 0) + e.betrag; + if (!monatKatMap[m]) monatKatMap[m] = {}; + monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag; }); - const maxMonat = Math.max(...Object.values(monatMap), 1); + const monatTotalMap = {}; + Object.entries(monatKatMap).forEach(([m, katObj]) => { + monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0); + }); + + const maxMonat = Math.max(...Object.values(monatTotalMap), 1); const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; const monatsBalken = MONATE.map((label, i) => { - const val = monatMap[i + 1] || 0; - const pct = Math.round((val / maxMonat) * 100); - const isAktiv = (i + 1) === (heute.getMonth() + 1); + const mi = i + 1; + const total = monatTotalMap[mi] || 0; + const pct = Math.round((total / maxMonat) * 100); + const isAktiv = mi === (heute.getMonth() + 1); + + // Top-2-Kategorien für gestapelten Balken + let stackHtml = ''; + if (total > 0 && monatKatMap[mi]) { + const sorted = Object.entries(monatKatMap[mi]) + .sort((a, b) => b[1] - a[1]) + .slice(0, 2); + // Gesamthöhe = pct%, verteile anteilig auf Top-2 + let rest = pct; + const segments = sorted.map(([katId, val], idx) => { + const k = _kat(katId); + const segPct = idx < sorted.length - 1 + ? Math.round((val / total) * pct) + : rest; + rest -= segPct; + return `
`; + }); + stackHtml = segments.join(''); + } else { + stackHtml = `
`; + } + return `
-
-
+
+ ${stackHtml}
${label}
`; }).join(''); + // Donut-Übersicht (CSS-gradient) + const donutHtml = _donutHtml(s, gesamtJahr); + el.innerHTML = ` -
-
Gesamt dieses Jahr
-
${_fmt(s.gesamt_jahr)}
+
+
Gesamt dieses Jahr
+
${_fmt(s.gesamt_jahr)}
@@ -341,6 +414,8 @@ window.Page_expenses = (() => {
${monatsBalken}
+ ${donutHtml} +
${UI.icon('chart-pie')} Aufteilung nach Kategorie
@@ -351,6 +426,45 @@ window.Page_expenses = (() => { `; } + // Donut via CSS conic-gradient + function _donutHtml(s, gesamt) { + const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0); + if (!aktiveKat.length) return ''; + + // Stops für conic-gradient berechnen + let offset = 0; + const stops = []; + aktiveKat.forEach(k => { + const pct = (s.jahr[k.id] || 0) / gesamt * 100; + stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`); + offset += pct; + }); + const gradient = `conic-gradient(${stops.join(', ')})`; + + const legendeItems = aktiveKat + .sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0)) + .map(k => { + const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100); + return ` +
+ + ${k.label} + ${pct}% +
`; + }).join(''); + + return ` +
+
${UI.icon('chart-pie')} Kategorien-Verteilung
+
+
+
+
+
${legendeItems}
+
+
`; + } + // ---------------------------------------------------------- // FORMULAR — Neu / Bearbeiten // ----------------------------------------------------------