- Übersicht: Hero-Card mit Gradient statt grauer Zeile, Vormonat-Trendpfeil (+/-%) - Kacheln: Icon oben, Betrag in primary-Farbe, Label klein darunter - Einträge: farbiges Icon-Badge (--kat-color), kompakter Monat-Trennstreifen mit Summe, Betrag fett rechts, Löschen-Icon direkt am Eintrag ohne Modal-Umweg - Statistik: gestapelte Top-2-Kategorien-Balken pro Monat (CSS-only), Donut-Diagramm via CSS conic-gradient, Kategorie-Legende - CSS: 435 neue Zeilen (exp-*) in components.css angehängt, keine bestehenden geändert
607 lines
21 KiB
JavaScript
607 lines
21 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 = [];
|
||
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;
|
||
|
||
// Vormonatsvergleich berechnen
|
||
const letzteMonat = await _getLetzteMonateData();
|
||
const trendHtml = _trendHtml(letzteMonat);
|
||
|
||
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-betrag" style="color:var(--c-primary)">${_fmt(betrag)}</div>
|
||
<div class="exp-kachel-label">${k.label}</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>
|
||
`;
|
||
}
|
||
|
||
async function _getLetzteMonateData() {
|
||
if (!_entries.length) {
|
||
_entries = await API.get('/expenses?limit=500');
|
||
}
|
||
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');
|
||
}
|
||
|
||
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: 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 (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) {
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
return { init, refresh };
|
||
})();
|