PHASE 1 — Sofort-Cleanup ohne Risiko: - Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen: * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3 * flex-between, flex-1-min, mb-1/3, mt-1/3 * icon-xs/sm/md/lg, label-block, caption - index.html bindet utilities.css ein - mb-3/mt-3 ergänzt (waren in design-system.css unvollständig) PHASE 2 — .by-tab Modifier für Vereinheitlichung: - .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.) - .by-tabs.sticky (Desktop vertikale Tabs für Admin) - .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll) - .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border) PHASE 3 — Inline-Style → Klassen-Migration (Python-Script): - 948 Inline-Styles entfernt (5101 → 4153, -18%) - 962 Migrationen über 47 Page-Dateien - Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67), litters.js (62), settings.js (61), zuchthunde.js (51) - Patterns: text-muted, text-secondary, text-danger, text-xs-muted, text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3, p-3/4, mb-2/3/4, hidden, w-full, flex-1, ... - Bewahrt bestehende class-Attribute (mergt korrekt) Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
875 lines
33 KiB
JavaScript
875 lines
33 KiB
JavaScript
/* ============================================================
|
||
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 => `
|
||
<button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}">
|
||
${d.id ? UI.icon('paw-print') : ''} ${_esc(d.name)}
|
||
</button>`).join('');
|
||
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
|
||
}
|
||
|
||
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>
|
||
${_dogSelectorHtml()}
|
||
<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-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 = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
|
||
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 = `<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' + _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
|
||
? `<div class="exp-kachel-jahr">${_fmt(monat)} diesen Monat</div>`
|
||
: '';
|
||
return `
|
||
<div class="exp-kachel" data-kat="${k.id}" style="cursor:pointer">
|
||
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
|
||
${UI.icon(k.icon)}
|
||
</div>
|
||
<div class="exp-kachel-betrag text-primary">${_fmt(jahr)}</div>
|
||
<div class="exp-kachel-label">${k.label}</div>
|
||
${monatLine}
|
||
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : '';
|
||
|
||
el.innerHTML = `
|
||
<div class="exp-hero-card">
|
||
<div class="exp-hero-label">Dieser Monat</div>
|
||
<div class="exp-hero-betrag">${_fmt(s.gesamt_monat)}</div>
|
||
<div class="exp-hero-meta">
|
||
${UI.icon('calendar')} Dieses Jahr: <strong>${_fmt(s.gesamt_jahr)}</strong>
|
||
${trendHtml}
|
||
</div>
|
||
</div>
|
||
<div class="exp-kachel-grid">${kacheln}</div>
|
||
${verlauf}
|
||
<div style="height:80px"></div>
|
||
`;
|
||
|
||
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
|
||
? `<span class="exp-trend exp-trend--up">${UI.icon('arrow-up')} +${pct}% ggü. Vormonat</span>`
|
||
: `<span class="exp-trend exp-trend--down">${UI.icon('arrow-down')} −${pct}% ggü. Vormonat</span>`;
|
||
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 `
|
||
<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' + _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
|
||
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
|
||
: '';
|
||
const notiz = e.notiz
|
||
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
|
||
: '';
|
||
return `
|
||
<div class="exp-entry" data-id="${e.id}">
|
||
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
|
||
${UI.icon(k.icon)}
|
||
</div>
|
||
<div class="exp-entry-body">
|
||
<div class="exp-entry-head">
|
||
<span class="exp-entry-datum">${datum}</span>
|
||
<span class="exp-entry-kat">${k.label}</span>
|
||
${dogBadge}
|
||
</div>
|
||
${notiz}
|
||
</div>
|
||
<div class="exp-entry-right">
|
||
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
|
||
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
|
||
aria-label="Eintrag löschen">
|
||
${UI.icon('trash')}
|
||
</button>
|
||
</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>`;
|
||
|
||
// 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 `
|
||
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
|
||
<div class="exp-entry-icon-badge" style="--kat-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>
|
||
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
|
||
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
|
||
</div>
|
||
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
|
||
<div class="exp-recurring-next">
|
||
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
|
||
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="exp-entry-right">
|
||
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
|
||
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
|
||
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
|
||
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
|
||
${UI.icon(r.aktiv ? 'pause' : 'play')}
|
||
</button>
|
||
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
|
||
title="Löschen">${UI.icon('trash')}</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
el.innerHTML = `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
|
||
${UI.icon('plus')} Dauerauftrag
|
||
</button>
|
||
</div>
|
||
${recurring.length
|
||
? `<div class="exp-list">${cards}</div>`
|
||
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
|
||
title: 'Keine Daueraufträge',
|
||
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
|
||
<div style="height:80px"></div>`;
|
||
|
||
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 => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
|
||
|
||
const dogOptions = (_appState.dogs || []).map(d =>
|
||
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
|
||
).join('');
|
||
|
||
const body = `
|
||
<form id="exp-recurring-form">
|
||
<div class="form-group">
|
||
<label class="form-label">Kategorie</label>
|
||
<select class="form-control" name="kategorie">${katOptions}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Betrag (€)</label>
|
||
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
|
||
value="${r?.betrag || ''}" placeholder="0,00" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Häufigkeit</label>
|
||
<select class="form-control" name="haeufigkeit">
|
||
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
|
||
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
|
||
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Startdatum</label>
|
||
<input class="form-control" type="date" name="startdatum"
|
||
value="${r?.startdatum || today}" required>
|
||
</div>
|
||
${dogOptions ? `
|
||
<div class="form-group">
|
||
<label class="form-label">Hund <span class="text-muted">(optional)</span></label>
|
||
<select class="form-control" name="dog_id">
|
||
<option value="">Kein Hund</option>${dogOptions}
|
||
</select>
|
||
</div>` : ''}
|
||
<div class="form-group">
|
||
<label class="form-label">Bezeichnung <span class="text-muted">(optional)</span></label>
|
||
<input class="form-control" type="text" name="notiz"
|
||
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
|
||
</div>
|
||
</form>`;
|
||
|
||
const footer = `
|
||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
|
||
|
||
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: parseFloat(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 `
|
||
<div class="exp-stat-row">
|
||
<div class="exp-stat-label">
|
||
<span class="exp-stat-icon" 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 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 `<div class="exp-stack-seg" style="height:${segPct}%;background:${k.color}" title="${k.label}: ${_fmt(val)}"></div>`;
|
||
});
|
||
stackHtml = segments.join('');
|
||
} else {
|
||
stackHtml = `<div class="exp-stack-seg" style="height:${pct}%;background:var(--c-border)"></div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
|
||
<div class="exp-bar-track exp-bar-track--stack">
|
||
${stackHtml}
|
||
</div>
|
||
<div class="exp-bar-label">${label}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Donut-Übersicht (CSS-gradient)
|
||
const donutHtml = _donutHtml(s, gesamtJahr);
|
||
|
||
el.innerHTML = `
|
||
<div class="exp-hero-card exp-hero-card--sm">
|
||
<div class="exp-hero-label">Gesamt dieses Jahr</div>
|
||
<div class="exp-hero-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>
|
||
|
||
${donutHtml}
|
||
|
||
<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>
|
||
`;
|
||
}
|
||
|
||
// 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 `
|
||
<div class="exp-donut-legend-item">
|
||
<span class="exp-donut-dot" style="background:${k.color}"></span>
|
||
<span class="exp-donut-legend-label">${k.label}</span>
|
||
<span class="exp-donut-legend-pct">${pct}%</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="exp-section">
|
||
<div class="exp-section-title">${UI.icon('chart-pie')} Kategorien-Verteilung</div>
|
||
<div class="exp-donut-wrap">
|
||
<div class="exp-donut" style="background:${gradient}">
|
||
<div class="exp-donut-hole"></div>
|
||
</div>
|
||
<div class="exp-donut-legend">${legendeItems}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 =>
|
||
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
|
||
).join('');
|
||
|
||
// Kategorie-Kacheln statt Dropdown
|
||
const katKacheln = KATEGORIEN.map(k => `
|
||
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
|
||
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} class="hidden">
|
||
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
|
||
<span class="exp-kat-tile-label">${k.label}</span>
|
||
</label>`).join('');
|
||
|
||
const body = `
|
||
<form id="${formId}" autocomplete="off">
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Kategorie</label>
|
||
<div class="exp-kat-grid">${katKacheln}</div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label class="form-label">Betrag</label>
|
||
<div class="exp-betrag-wrap">
|
||
<span class="exp-betrag-prefix">€</span>
|
||
<input type="number" name="betrag" class="form-control exp-betrag-input"
|
||
value="${entry?.betrag || ''}" min="0.01" step="0.01"
|
||
placeholder="0,00" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label class="form-label">Datum</label>
|
||
<input type="date" name="datum" class="form-control"
|
||
value="${entry?.datum || today}" required>
|
||
</div>
|
||
</div>
|
||
|
||
${dogOptions ? `
|
||
<div class="form-group">
|
||
<label class="form-label">Hund <span class="form-label-hint">(optional)</span></label>
|
||
<select name="dog_id" class="form-control">
|
||
<option value="">— kein Hund —</option>${dogOptions}
|
||
</select>
|
||
</div>` : ''}
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
|
||
<input type="text" name="notiz" class="form-control"
|
||
value="${_esc(entry?.notiz || '')}"
|
||
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
|
||
</div>
|
||
|
||
${!isEdit ? `
|
||
<div class="exp-repeat-section">
|
||
<label class="exp-repeat-toggle">
|
||
<input type="checkbox" id="exp-wiederholen" name="wiederholen">
|
||
<span class="exp-repeat-toggle-box"></span>
|
||
<span>${UI.icon('arrows-clockwise')} Automatisch wiederholen</span>
|
||
</label>
|
||
<div id="exp-repeat-opts" style="display:none;margin-top:var(--space-3)">
|
||
<select name="haeufigkeit" class="form-control">
|
||
<option value="monatlich">Monatlich</option>
|
||
<option value="quartalsweise">Quartalsweise (alle 3 Monate)</option>
|
||
<option value="jaehrlich" selected>Jährlich</option>
|
||
</select>
|
||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0">
|
||
Der Betrag wird automatisch zum Fälligkeitstermin eingetragen.
|
||
</p>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
</form>`;
|
||
|
||
const footer = isEdit ? `
|
||
<button type="button" class="btn btn-ghost btn-sm" id="exp-delete-btn"
|
||
style="color:var(--c-danger);margin-right:auto">
|
||
${UI.icon('trash')}
|
||
</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>
|
||
` : `
|
||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
|
||
`;
|
||
|
||
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: 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}`, 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, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
return { init, refresh };
|
||
})();
|