/** * Modul: Budget-Tracker (Budget) * Zweck: Monatsübersicht, Kategorie-Balkendiagramm (Canvas), Transaktionsliste, * CRUD, CSV-Export * Abhängigkeiten: /api.js, /router.js (window.oikos) */ import { api } from '/api.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const CATEGORIES = [ 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', ]; const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; // -------------------------------------------------------- // State // -------------------------------------------------------- let state = { month: '', // YYYY-MM entries: [], summary: null, }; let _container = null; // -------------------------------------------------------- // Formatierung // -------------------------------------------------------- function formatAmount(n) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n); } function formatMonthLabel(ym) { const [y, m] = ym.split('-'); return `${MONTH_NAMES[parseInt(m, 10) - 1]} ${y}`; } function addMonths(ym, n) { const [y, m] = ym.split('-').map(Number); const d = new Date(y, m - 1 + n, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } // -------------------------------------------------------- // API // -------------------------------------------------------- async function loadMonth(month) { const [entriesRes, summaryRes] = await Promise.all([ api.get(`/budget?month=${month}`), api.get(`/budget/summary?month=${month}`), ]); state.month = month; state.entries = entriesRes.data; state.summary = summaryRes.data; } // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- export async function render(container, { user }) { _container = container; const today = new Date(); state.month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; container.innerHTML = `
Lade…
`; if (window.lucide) lucide.createIcons(); await loadMonth(state.month); renderBody(); wireNav(); } // -------------------------------------------------------- // Navigation // -------------------------------------------------------- function wireNav() { _container.querySelector('#budget-prev').addEventListener('click', async () => { await loadMonth(addMonths(state.month, -1)); renderBody(); updateLabel(); }); _container.querySelector('#budget-next').addEventListener('click', async () => { await loadMonth(addMonths(state.month, 1)); renderBody(); updateLabel(); }); _container.querySelector('#budget-today').addEventListener('click', async () => { const today = new Date(); const m = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; if (m === state.month) return; await loadMonth(m); renderBody(); updateLabel(); }); _container.querySelector('#budget-add').addEventListener('click', () => openModal({ mode: 'create' })); updateLabel(); } function updateLabel() { const lbl = _container.querySelector('#budget-label'); if (lbl) lbl.textContent = formatMonthLabel(state.month); } // -------------------------------------------------------- // Body // -------------------------------------------------------- function renderBody() { const body = _container.querySelector('#budget-body'); if (!body) return; updateLabel(); const s = state.summary; const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative'; body.innerHTML = `
Einnahmen
${formatAmount(s.income)}
Ausgaben
${formatAmount(Math.abs(s.expenses))}
Saldo
${formatAmount(s.balance)}
${s.byCategory.length ? `
Nach Kategorie
${renderCategoryBars(s.byCategory)}
` : ''}
Transaktionen ${state.entries.length ? ` CSV ` : ''}
${renderEntries()}
`; if (window.lucide) lucide.createIcons(); _container.querySelector('#budget-list')?.addEventListener('click', async (e) => { const delBtn = e.target.closest('[data-action="delete"]'); if (delBtn) { await deleteEntry(parseInt(delBtn.dataset.id, 10)); return; } const item = e.target.closest('.budget-entry[data-id]'); if (item && !e.target.closest('[data-action]')) { const entry = state.entries.find((e) => e.id === parseInt(item.dataset.id, 10)); if (entry) openModal({ mode: 'edit', entry }); } }); } function renderCategoryBars(byCategory) { const maxAbs = Math.max(...byCategory.map((c) => Math.abs(c.total)), 1); return byCategory.map((c) => { const isExpense = c.total < 0; const pct = Math.round((Math.abs(c.total) / maxAbs) * 100); const cls = isExpense ? 'budget-bar-row__fill--expenses' : 'budget-bar-row__fill--income'; return `
${escHtml(c.category)}
${formatAmount(c.total)}
`; }).join(''); } function renderEntries() { if (!state.entries.length) { return `
Keine Einträge
Noch keine Transaktionen für diesen Monat.
`; } return state.entries.map((e) => { const isIncome = e.amount > 0; const amtClass = isIncome ? 'budget-entry__amount--income' : 'budget-entry__amount--expenses'; const indClass = isIncome ? 'budget-entry__indicator--income' : 'budget-entry__indicator--expenses'; const sign = isIncome ? '+' : ''; const date = formatEntryDate(e.date); return `
${escHtml(e.title)}
${sign}${formatAmount(e.amount)}
`; }).join(''); } function formatEntryDate(dateStr) { const d = new Date(dateStr + 'T00:00:00'); return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.`; } // -------------------------------------------------------- // Modal // -------------------------------------------------------- function openModal({ mode, entry = null }) { document.querySelector('#budget-modal-overlay')?.remove(); const isEdit = mode === 'edit'; const today = new Date().toISOString().slice(0, 10); const isExpense = isEdit ? entry.amount < 0 : true; // Standard: Ausgabe const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; const catOpts = CATEGORIES.map((c) => `` ).join(''); const overlay = document.createElement('div'); overlay.id = 'budget-modal-overlay'; overlay.className = 'budget-modal-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); if (window.lucide) lucide.createIcons(); // Typ-Toggle let currentType = isExpense ? 'expense' : 'income'; overlay.querySelector('#type-expense').addEventListener('click', () => { currentType = 'expense'; overlay.querySelector('#type-expense').classList.add('amount-type-btn--active'); overlay.querySelector('#type-income').classList.remove('amount-type-btn--active'); }); overlay.querySelector('#type-income').addEventListener('click', () => { currentType = 'income'; overlay.querySelector('#type-income').classList.add('amount-type-btn--active'); overlay.querySelector('#type-expense').classList.remove('amount-type-btn--active'); }); overlay.querySelector('#bm-close').addEventListener('click', () => overlay.remove()); overlay.querySelector('#bm-cancel').addEventListener('click', () => overlay.remove()); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); overlay.querySelector('#bm-delete')?.addEventListener('click', async () => { if (!confirm(`"${entry.title}" wirklich löschen?`)) return; overlay.remove(); await deleteEntry(entry.id); }); overlay.querySelector('#bm-save').addEventListener('click', async () => { const saveBtn = overlay.querySelector('#bm-save'); const title = overlay.querySelector('#bm-title').value.trim(); const absVal = parseFloat(overlay.querySelector('#bm-amount').value); const category = overlay.querySelector('#bm-category').value; const date = overlay.querySelector('#bm-date').value; const recurring = overlay.querySelector('#bm-recurring').checked ? 1 : 0; if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; } if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast('Gültigen Betrag eingeben', 'error'); return; } if (!date) { window.oikos?.showToast('Datum ist erforderlich', 'error'); return; } const amount = currentType === 'expense' ? -absVal : absVal; saveBtn.disabled = true; saveBtn.textContent = '…'; try { const body = { title, amount, category, date, is_recurring: recurring }; if (mode === 'create') { const res = await api.post('/budget', body); state.entries.unshift(res.data); } else { const res = await api.put(`/budget/${entry.id}`, body); const idx = state.entries.findIndex((e) => e.id === entry.id); if (idx !== -1) state.entries[idx] = res.data; } // Zusammenfassung neu laden const sumRes = await api.get(`/budget/summary?month=${state.month}`); state.summary = sumRes.data; overlay.remove(); renderBody(); window.oikos?.showToast(mode === 'create' ? 'Eintrag hinzugefügt' : 'Eintrag gespeichert', 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); saveBtn.disabled = false; saveBtn.textContent = isEdit ? 'Speichern' : 'Hinzufügen'; } }); overlay.querySelector('#bm-title').focus(); } // -------------------------------------------------------- // Eintrag löschen // -------------------------------------------------------- async function deleteEntry(id) { if (!confirm('Eintrag wirklich löschen?')) return; try { await api.delete(`/budget/${id}`); state.entries = state.entries.filter((e) => e.id !== id); const sumRes = await api.get(`/budget/summary?month=${state.month}`); state.summary = sumRes.data; renderBody(); window.oikos?.showToast('Eintrag gelöscht', 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); } } // -------------------------------------------------------- // Hilfsfunktion // -------------------------------------------------------- function escHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }