/* ============================================================
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')} ${UI.escape(e.dog_name)}`
: '';
const notiz = e.notiz
? `${UI.escape(e.notiz)}
`
: '';
return `
${UI.icon(k.icon)}
${k.label}
${notiz}
${datum}
${dogBadge ? `· ${dogBadge}` : ''}
${_fmt(e.betrag)}
`;
}).join('');
return `
${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 ? `
${UI.escape(r.notiz)}
` : ''}
${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}
· ${UI.icon('calendar')} ${naechste}
${r.dog_name ? `· ${UI.icon('paw-print')} ${UI.escape(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 = `
`;
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 `
`;
}).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
`;
}
// ----------------------------------------------------------
// 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 = `
`;
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) + ' €';
}
return { init, refresh };
})();