/* ============================================================ BAN YARO — Ausgaben-Tracker Tabs: Übersicht | Einträge | Statistik ============================================================ */ window.Page_expenses = (() => { let _container = null; let _appState = null; let _tab = 'uebersicht'; let _selectedDogId = null; // Cache let _summary = null; let _entries = []; let _statsData = null; function _dogParam() { return _selectedDogId ? `?dog_id=${_selectedDogId}` : ''; } function _dogParamAnd() { return _selectedDogId ? `&dog_id=${_selectedDogId}` : ''; } function _clearCache() { _summary = null; _entries = []; _statsData = null; } const TABS = [ { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, { id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' }, { id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' }, { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, ]; const KATEGORIEN = [ { id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' }, { id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' }, { id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' }, { id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' }, { id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' }, { id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' }, ]; function _kat(id) { return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' }; } // ---------------------------------------------------------- // LIFECYCLE // ---------------------------------------------------------- async function init(container, appState) { _container = container; _appState = appState; _selectedDogId = null; _clearCache(); _render(); } async function refresh() { _summary = null; _entries = []; _statsData = null; await _renderTab(); } // ---------------------------------------------------------- // SHELL // ---------------------------------------------------------- function _dogSelectorHtml() { const dogs = _appState?.dogs || []; if (dogs.length < 2) return ''; const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => ` `).join(''); return `
${pills}
`; } function _render() { _container.innerHTML = `
${TABS.map(t => ` `).join('')}
${_dogSelectorHtml()}
`; _container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => { btn.addEventListener('click', () => { _tab = btn.dataset.tab; _container.querySelectorAll('#exp-tabs .by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab) ); _renderTab(); }); }); _container.querySelector('#exp-dog-selector')?.addEventListener('click', e => { const pill = e.target.closest('.exp-dog-pill'); if (!pill) return; _selectedDogId = pill.dataset.dog ? parseInt(pill.dataset.dog) : null; _clearCache(); _container.querySelectorAll('.exp-dog-pill').forEach(p => p.classList.toggle('active', p.dataset.dog === (pill.dataset.dog)) ); _renderTab(); }); _container.querySelector('#exp-fab') ?.addEventListener('click', () => _showForm(null)); _renderTab(); } // ---------------------------------------------------------- // TAB ROUTER // ---------------------------------------------------------- async function _renderTab() { const el = _container.querySelector('#exp-content'); if (!el) return; el.innerHTML = `
${UI.skeleton(4)}
`; try { switch (_tab) { case 'uebersicht': await _renderUebersicht(el); break; case 'eintraege': await _renderEintraege(el); break; case 'dauerauftraege': await _renderDauerauftraege(el); break; case 'statistik': await _renderStatistik(el); break; } } catch (e) { el.innerHTML = `
Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}
`; } } // ---------------------------------------------------------- // TAB: ÜBERSICHT // ---------------------------------------------------------- async function _renderUebersicht(el) { if (!_summary) { _summary = await API.get('/expenses/summary' + _dogParam()); } const s = _summary; // Vormonatsvergleich berechnen const letzteMonat = await _getLetzteMonateData(); const trendHtml = _trendHtml(letzteMonat); const kacheln = KATEGORIEN.map(k => { const monat = s.monat[k.id] || 0; const jahr = s.jahr[k.id] || 0; const monatLine = monat > 0 ? `
${_fmt(monat)} diesen Monat
` : ''; return `
${UI.icon(k.icon)}
${_fmt(jahr)}
${k.label}
${monatLine}
${UI.icon('plus')} eintragen
`; }).join(''); const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : ''; el.innerHTML = `
Dieser Monat
${_fmt(s.gesamt_monat)}
${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)} ${trendHtml}
${kacheln}
${verlauf}
`; el.querySelectorAll('.exp-kachel[data-kat]').forEach(k => { k.addEventListener('click', () => _showForm(null, k.dataset.kat)); }); } async function _getLetzteMonateData() { if (!_entries.length) { _entries = await API.get('/expenses?limit=500' + _dogParamAnd()); } const monatMap = {}; _entries.forEach(e => { const m = e.datum.substring(0, 7); monatMap[m] = (monatMap[m] || 0) + e.betrag; }); return Object.entries(monatMap) .sort((a, b) => b[0].localeCompare(a[0])) .slice(0, 6) .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); const balken = data.map(([monat, summe]) => { const pct = Math.round((summe / max) * 100); const [y, m] = monat.split('-'); const label = new Date(parseInt(y), parseInt(m) - 1, 1) .toLocaleString('de-DE', { month: 'short' }); return `
${label}
${_fmtShort(summe)}
`; }).join(''); return `
${UI.icon('chart-bar')} Verlauf (6 Monate)
${balken}
`; } // ---------------------------------------------------------- // TAB: EINTRÄGE // ---------------------------------------------------------- async function _renderEintraege(el) { if (!_entries.length) { _entries = await API.get('/expenses?limit=500' + _dogParamAnd()); } if (!_entries.length) { el.innerHTML = UI.emptyState({ icon: UI.icon('receipt'), title: 'Noch keine Ausgaben', text: 'Tippe auf + um deine erste Ausgabe einzutragen.', }); return; } // Nach Monat gruppieren const groups = {}; _entries.forEach(e => { const m = e.datum.substring(0, 7); if (!groups[m]) groups[m] = []; groups[m].push(e); }); const html = Object.entries(groups) .sort((a, b) => b[0].localeCompare(a[0])) .map(([monat, items]) => { const [y, m] = monat.split('-'); const titel = new Date(parseInt(y), parseInt(m) - 1, 1) .toLocaleString('de-DE', { month: 'long', year: 'numeric' }); const summe = items.reduce((s, e) => s + e.betrag, 0); const rows = items.map(e => { const k = _kat(e.kategorie); const datum = new Date(e.datum + 'T00:00:00') .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); const dogBadge = e.dog_name ? `${UI.icon('paw-print')} ${_esc(e.dog_name)}` : ''; const notiz = e.notiz ? `
${_esc(e.notiz)}
` : ''; return `
${UI.icon(k.icon)}
${k.label}
${notiz}
${datum} ${dogBadge ? `· ${dogBadge}` : ''}
${_fmt(e.betrag)}
`; }).join(''); return `
${titel} ${_fmt(summe)}
${rows}
`; }).join(''); el.innerHTML = `
${html}
`; // Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button) el.querySelectorAll('.exp-entry').forEach(row => { 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.'); } }); }); } // ---------------------------------------------------------- // TAB: DAUERAUFTRÄGE // ---------------------------------------------------------- const HAEUFIGKEIT_LABEL = { monatlich: 'Monatlich', quartalsweise: 'Quartalsweise', jaehrlich: 'Jährlich', }; async function _renderDauerauftraege(el) { let recurring = []; try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ } if (_selectedDogId) recurring = recurring.filter(r => r.dog_id === _selectedDogId); const cards = recurring.map(r => { const k = _kat(r.kategorie); const naechste = r.naechste_faelligkeit ? new Date(r.naechste_faelligkeit + 'T00:00:00') .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '—'; return `
${UI.icon(k.icon)}
${k.label}
${r.notiz ? `
${_esc(r.notiz)}
` : ''}
${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit} · ${UI.icon('calendar')} ${naechste} ${r.dog_name ? `· ${UI.icon('paw-print')} ${_esc(r.dog_name)}` : ''} ${!r.aktiv ? '· Pausiert' : ''}
${_fmt(r.betrag)}
`; }).join(''); el.innerHTML = `
${recurring.length ? `
${cards}
` : UI.emptyState({ icon: UI.icon('arrows-clockwise'), title: 'Keine Daueraufträge', text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
`; el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => { _tab = 'dauerauftraege'; _renderTab(); })); el.querySelectorAll('.exp-recurring-toggle').forEach(btn => { btn.addEventListener('click', async () => { const rid = parseInt(btn.dataset.rid); const aktiv = btn.dataset.aktiv === '1'; await UI.asyncButton(btn, async () => { await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv }); _renderTab(); }); }); }); el.querySelectorAll('.exp-recurring-del').forEach(btn => { btn.addEventListener('click', async () => { if (!window.confirm('Dauerauftrag löschen?')) return; await UI.asyncButton(btn, async () => { await API.del(`/expenses/recurring/${btn.dataset.rid}`); _renderTab(); }); }); }); } function _showRecurringForm(r, onSave) { const today = new Date().toISOString().slice(0, 10); const katOptions = [ { id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' }, { id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' }, { id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' }, ].map(k => ``).join(''); const dogOptions = (_appState.dogs || []).map(d => `` ).join(''); const body = `
${UI.moneyInput({ name: 'betrag', value: r?.betrag ?? '', required: true })}
${dogOptions ? `
` : ''}
`; const footer = ` `; UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer }); document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]'); const fd = UI.formData(e.target); const payload = { kategorie: fd.kategorie, betrag: UI.parseMoney(fd.betrag), haeufigkeit: fd.haeufigkeit, startdatum: fd.startdatum, notiz: fd.notiz || null, dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, }; await UI.asyncButton(btn, async () => { if (r) { await API.patch(`/expenses/recurring/${r.id}`, payload); } else { await API.post('/expenses/recurring', payload); } UI.modal.close(); onSave?.(); }); }); } // ---------------------------------------------------------- // TAB: STATISTIK // ---------------------------------------------------------- async function _renderStatistik(el) { if (!_summary) { _summary = await API.get('/expenses/summary' + _dogParam()); } if (!_entries.length) { _entries = await API.get('/expenses?limit=500' + _dogParamAnd()); } const s = _summary; const gesamtJahr = s.gesamt_jahr || 1; // 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)) .map(k => { const val = s.jahr[k.id] || 0; const pct = Math.round((val / gesamtJahr) * 100); return `
${UI.icon(k.icon)} ${k.label}
${pct}%
${_fmt(val)}
`; }).join(''); // Monats-Balken mit gestapelten Top-2-Kategorien const heute = new Date(); const jahrStr = heute.getFullYear().toString(); // 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]); if (!monatKatMap[m]) monatKatMap[m] = {}; monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag; }); 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 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)}
${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}
${monatsBalken}
${donutHtml}
${UI.icon('chart-pie')} Aufteilung nach Kategorie
${katBalken || `
Noch keine Ausgaben dieses Jahr.
`}
`; } // 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 // ---------------------------------------------------------- function _showForm(entry, preKat) { const isEdit = !!entry; const today = new Date().toISOString().split('T')[0]; const formId = 'exp-form'; const selKat = entry?.kategorie || preKat || 'sonstiges'; const defaultDogId = entry?.dog_id ?? _selectedDogId; const dogOptions = (_appState.dogs || []).map(d => `` ).join(''); // Kategorie-Kacheln statt Dropdown const katKacheln = KATEGORIEN.map(k => ` `).join(''); const body = `
${katKacheln}
${UI.moneyInput({ name: 'betrag', value: entry?.betrag ?? '', required: true })}
${dogOptions ? `
` : ''}
${!isEdit ? `
` : ''}
`; const footer = isEdit ? ` ` : ` `; const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer }); // Betrag-Feld fokussieren (besonders beim Schnelleintrag per Kachel) setTimeout(() => modal.querySelector('input[name="betrag"]')?.focus(), 200); // Kategorie-Kacheln interaktiv modal.querySelectorAll('.exp-kat-tile').forEach(tile => { tile.addEventListener('click', () => { modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel')); tile.classList.add('exp-kat-tile--sel'); }); }); // Wiederholen-Toggle modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => { modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none'; }); if (isEdit) { modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => { if (!window.confirm('Diesen Eintrag wirklich löschen?')) return; try { await API.del(`/expenses/${entry.id}`); UI.modal.close(); UI.toast.success('Ausgabe gelöscht.'); _invalidateCache(); await _renderTab(); } catch (e) { UI.toast.error(e.message || 'Fehler beim Löschen.'); } }); } modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => { ev.preventDefault(); const fd = UI.formData(ev.target); const payload = { kategorie: fd.kategorie, betrag: UI.parseMoney(fd.betrag), datum: fd.datum, notiz: fd.notiz || null, dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, }; try { if (isEdit) { await API.patch(`/expenses/${entry.id}`, payload); UI.toast.success('Ausgabe aktualisiert.'); } else { await API.post('/expenses', payload); // Auch als Dauerauftrag anlegen wenn gewünscht if (fd.wiederholen) { await API.post('/expenses/recurring', { ...payload, haeufigkeit: fd.haeufigkeit || 'jaehrlich', startdatum: fd.datum, }); UI.toast.success('Ausgabe + Dauerauftrag gespeichert.'); } else { UI.toast.success('Ausgabe gespeichert.'); } } UI.modal.close(); _invalidateCache(); await _renderTab(); } catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); } }); } // ---------------------------------------------------------- // Hilfsfunktionen // ---------------------------------------------------------- function _invalidateCache() { _summary = null; _entries = []; _statsData = null; } function _fmt(val) { return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }); } function _fmtShort(val) { if (!val) return '0 €'; if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€'; return Math.round(val) + ' €'; } function _esc(s) { if (!s) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } return { init, refresh }; })();