banyaro/backend/static/js/pages/expenses.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
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).
2026-05-27 07:11:27 +02:00

875 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh };
})();