/* ============================================================
BAN YARO — Ausgaben-Tracker
Tabs: Übersicht | Einträge | Statistik
============================================================ */
window.Page_expenses = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
// Cache
let _summary = null;
let _entries = [];
// Monats-Statistik-Daten (pro Monat und Kategorie)
let _statsData = null;
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Einträge', icon: 'list-bullets' },
{ 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;
_summary = null;
_entries = [];
_statsData = null;
_render();
}
async function refresh() {
_summary = null;
_entries = [];
_statsData = null;
await _renderTab();
}
// ----------------------------------------------------------
// SHELL
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
${TABS.map(t => `
`).join('')}
`;
_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-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 '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');
}
const s = _summary;
const kacheln = KATEGORIEN.map(k => {
const betrag = s.monat[k.id] || 0;
return `
${UI.icon(k.icon)}
${k.label}
${_fmt(betrag)}
`;
}).join('');
const letzteMonat = await _getLetzteMonateData();
const vergleich = letzteMonat.length > 1
? _vergleichHtml(letzteMonat)
: '';
el.innerHTML = `
Dieser Monat
${_fmt(s.gesamt_monat)}
${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)}
${kacheln}
${vergleich}
`;
}
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
monatMap[m] = (monatMap[m] || 0) + e.betrag;
});
return Object.entries(monatMap)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 6)
.reverse();
}
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');
}
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}
${dogBadge}
${datum}
${notiz}
${_fmt(e.betrag)}
`;
}).join('');
return `
${rows}
`;
}).join('');
el.innerHTML = `${html}
`;
el.querySelectorAll('.exp-entry').forEach(row => {
row.addEventListener('click', () => {
const id = parseInt(row.dataset.id);
const entry = _entries.find(e => e.id === id);
if (entry) _showForm(entry);
});
});
}
// ----------------------------------------------------------
// TAB: STATISTIK
// ----------------------------------------------------------
async function _renderStatistik(el) {
if (!_summary) {
_summary = await API.get('/expenses/summary');
}
if (!_entries.length) {
_entries = await API.get('/expenses?limit=500');
}
const s = _summary;
const gesamtJahr = s.gesamt_jahr || 1;
// Jahres-Aufteilung nach Kategorien
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 (aktuelles Jahr, Monat für Monat)
const heute = new Date();
const jahrStr = heute.getFullYear().toString();
const monatMap = {};
_entries
.filter(e => e.datum.startsWith(jahrStr))
.forEach(e => {
const m = parseInt(e.datum.split('-')[1]);
monatMap[m] = (monatMap[m] || 0) + e.betrag;
});
const maxMonat = Math.max(...Object.values(monatMap), 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);
return `
`;
}).join('');
el.innerHTML = `
Gesamt dieses Jahr
${_fmt(s.gesamt_jahr)}
${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}
${monatsBalken}
${UI.icon('chart-pie')} Aufteilung nach Kategorie
${katBalken || `
Noch keine Ausgaben dieses Jahr.
`}
`;
}
// ----------------------------------------------------------
// FORMULAR — Neu / Bearbeiten
// ----------------------------------------------------------
function _showForm(entry) {
const isEdit = !!entry;
const today = new Date().toISOString().split('T')[0];
const formId = 'exp-form';
const dogOptions = (_appState.dogs || []).map(d =>
``
).join('');
const katOptions = KATEGORIEN.map(k =>
``
).join('');
const body = `
`;
const footer = isEdit ? `
` : `
`;
const modal = UI.modal.open({
title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe',
body,
footer,
});
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 body = {
kategorie: fd.kategorie,
betrag: parseFloat(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}`, body);
UI.toast.success('Ausgabe aktualisiert.');
} else {
await API.post('/expenses', body);
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 };
})();