banyaro/backend/static/js/pages/expenses.js

493 lines
16 KiB
JavaScript

/* ============================================================
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 = `
<div class="by-tabs exp-tabs" id="exp-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
</button>
`;
_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 = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
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 = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
}
}
// ----------------------------------------------------------
// 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 `
<div class="exp-kachel">
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-kachel-label">${k.label}</div>
<div class="exp-kachel-betrag">${_fmt(betrag)}</div>
</div>`;
}).join('');
const letzteMonat = await _getLetzteMonateData();
const vergleich = letzteMonat.length > 1
? _vergleichHtml(letzteMonat)
: '';
el.innerHTML = `
<div class="exp-gesamt-card">
<div class="exp-gesamt-label">Dieser Monat</div>
<div class="exp-gesamt-betrag">${_fmt(s.gesamt_monat)}</div>
<div class="exp-gesamt-sub">
${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)}
</div>
</div>
<div class="exp-kachel-grid">${kacheln}</div>
${vergleich}
<div style="height:80px"></div>
`;
}
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 `
<div class="exp-bar-item">
<div class="exp-bar-track">
<div class="exp-bar-fill" style="height:${pct}%"></div>
</div>
<div class="exp-bar-label">${label}</div>
<div class="exp-bar-val">${_fmtShort(summe)}</div>
</div>`;
}).join('');
return `
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Verlauf (6 Monate)</div>
<div class="exp-bar-chart">${balken}</div>
</div>`;
}
// ----------------------------------------------------------
// 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
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
: '';
const notiz = e.notiz
? `<span class="exp-notiz">${_esc(e.notiz)}</span>`
: '';
return `
<div class="exp-entry" data-id="${e.id}">
<div class="exp-entry-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
<span class="exp-entry-datum">${datum}</span>
</div>
${notiz}
</div>
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
</div>`;
}).join('');
return `
<div class="exp-month-group">
<div class="exp-month-header">
<span class="exp-month-title">${titel}</span>
<span class="exp-month-summe">${_fmt(summe)}</span>
</div>
${rows}
</div>`;
}).join('');
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
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 `
<div class="exp-stat-row">
<div class="exp-stat-label">
<span style="color:${k.color}">${UI.icon(k.icon)}</span>
${k.label}
</div>
<div class="exp-stat-bar-wrap">
<div class="exp-stat-bar" style="width:${pct}%;background:${k.color}"></div>
</div>
<div class="exp-stat-pct">${pct}%</div>
<div class="exp-stat-val">${_fmt(val)}</div>
</div>`;
}).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 `
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
<div class="exp-bar-track">
<div class="exp-bar-fill${isAktiv ? ' exp-bar-fill--aktiv' : ''}"
style="height:${pct}%"></div>
</div>
<div class="exp-bar-label">${label}</div>
</div>`;
}).join('');
el.innerHTML = `
<div class="exp-gesamt-card exp-gesamt-card--sm">
<div class="exp-gesamt-label">Gesamt dieses Jahr</div>
<div class="exp-gesamt-betrag">${_fmt(s.gesamt_jahr)}</div>
</div>
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}</div>
<div class="exp-bar-chart exp-bar-chart--12">${monatsBalken}</div>
</div>
<div class="exp-section">
<div class="exp-section-title">${UI.icon('chart-pie')} Aufteilung nach Kategorie</div>
<div class="exp-stat-rows">
${katBalken || `<div class="exp-empty-hint">Noch keine Ausgaben dieses Jahr.</div>`}
</div>
</div>
<div style="height:80px"></div>
`;
}
// ----------------------------------------------------------
// 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 =>
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
).join('');
const katOptions = KATEGORIEN.map(k =>
`<option value="${k.id}"${(entry?.kategorie || 'sonstiges') === k.id ? ' selected' : ''}>
${k.label}
</option>`
).join('');
const body = `
<form id="${formId}">
<div class="form-group">
<label class="form-label">Datum</label>
<input type="date" name="datum" class="form-input"
value="${entry?.datum || today}" required>
</div>
<div class="form-group">
<label class="form-label">Kategorie</label>
<select name="kategorie" class="form-input" required>
${katOptions}
</select>
</div>
<div class="form-group">
<label class="form-label">Betrag (€)</label>
<input type="number" name="betrag" class="form-input"
value="${entry?.betrag || ''}"
min="0.01" step="0.01" placeholder="0,00" required>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund (optional)</label>
<select name="dog_id" class="form-input">
<option value="">— kein Hund zugeordnet —</option>
${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Notiz (optional)</label>
<input type="text" name="notiz" class="form-input"
value="${_esc(entry?.notiz || '')}"
placeholder="z. B. Impfung, Trockenfutter Vorrat …">
</div>
</form>`;
const footer = isEdit ? `
<button type="button" class="btn btn-danger" id="exp-delete-btn">Löschen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
` : `
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh };
})();