/** * 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'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, getLocale } from '/i18n.js'; import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const CATEGORIES = [ 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', ]; const CATEGORY_LABELS = () => ({ 'Lebensmittel': t('budget.catFood'), 'Miete': t('budget.catRent'), 'Versicherung': t('budget.catInsurance'), 'Mobilität': t('budget.catMobility'), 'Freizeit': t('budget.catLeisure'), 'Kleidung': t('budget.catClothing'), 'Gesundheit': t('budget.catHealth'), 'Bildung': t('budget.catEducation'), 'Sonstiges': t('budget.catMisc'), }); function getMonthName(monthIndex) { // monthIndex: 0-based (0=Januar, 11=Dezember) const date = new Date(2000, monthIndex, 1); return new Intl.DateTimeFormat(getLocale(), { month: 'long' }).format(date); } // -------------------------------------------------------- // State // -------------------------------------------------------- let state = { month: '', // YYYY-MM entries: [], summary: null, prevSummary: null, // Vormonat für Monatsvergleich }; 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 `${getMonthName(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 prevMonth = addMonths(month, -1); try { const [entriesRes, summaryRes, prevSummaryRes] = await Promise.all([ api.get(`/budget?month=${month}`), api.get(`/budget/summary?month=${month}`), api.get(`/budget/summary?month=${prevMonth}`), ]); state.month = month; state.entries = entriesRes.data; state.summary = summaryRes.data; state.prevSummary = prevSummaryRes.data; } catch (err) { console.error('[Budget] loadMonth Fehler:', err); state.month = month; state.entries = []; state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] }; state.prevSummary = null; window.oikos?.showToast(t('budget.loadError'), 'danger'); } } // -------------------------------------------------------- // 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 = `

${t('budget.title')}

${t('budget.loadingIndicator')}
`; 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(); }); const addHandler = () => openBudgetModal({ mode: 'create' }); _container.querySelector('#budget-add').addEventListener('click', addHandler); _container.querySelector('#fab-new-budget').addEventListener('click', addHandler); 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 p = state.prevSummary; const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative'; const prevLabel = p ? formatMonthLabel(p.month).split(' ')[0].slice(0, 3) : ''; body.innerHTML = `
${t('budget.income')}
${formatAmount(s.income)}
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
${t('budget.expenses')}
${formatAmount(Math.abs(s.expenses))}
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
${t('budget.balance')}
${formatAmount(s.balance)}
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
${s.byCategory.length ? `
${t('budget.byCategory')}
${renderCategoryBars(s.byCategory)}
` : ''}
${t('budget.transactions')} ${state.entries.length ? ` CSV ` : ''}
${renderEntries()}
`; if (window.lucide) lucide.createIcons(); stagger(_container.querySelector('#budget-list')?.querySelectorAll('.budget-entry') ?? []); _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) openBudgetModal({ 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 `
${esc(c.category)}
${formatAmount(c.total)}
`; }).join(''); } function renderEntries() { if (!state.entries.length) { return `
${t('budget.emptyTitle')}
${t('budget.emptyDescription')}
`; } 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); const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : ''); return `
${esc(e.title)}
${sign}${formatAmount(e.amount)}
`; }).join(''); } /** * Rendert eine Trend-Zeile im Vergleich zum Vormonat. * Alle drei Metriken (income, expenses, balance) nutzen dieselbe Logik: * delta > 0 → positiver Trend (▲ grün), delta < 0 → negativer Trend (▼ rot). * Ausgaben werden als negative Zahlen übergeben, daher gilt: * weniger Ausgaben ↔ delta > 0 ↔ gut. * @param {number} current Aktueller Wert * @param {number} prev Vormonatswert * @param {string} prevLabel Kurzname des Vormonats (z.B. "Mär") */ function renderTrend(current, prev, prevLabel) { const delta = current - prev; if (Math.abs(delta) < 0.005) { return `
${t('budget.trendNeutral', { month: prevLabel })}
`; } const positive = delta > 0; const arrow = positive ? '▲' : '▼'; const sign = positive ? '+' : ''; const cls = positive ? 'budget-summary-card__trend--positive' : 'budget-summary-card__trend--negative'; return `
${arrow} ${sign}${formatAmount(delta)} vs. ${prevLabel}
`; } function formatEntryDate(dateStr) { return formatDate(new Date(dateStr + 'T00:00:00')); } // -------------------------------------------------------- // Modal // -------------------------------------------------------- function openBudgetModal({ mode, entry = null }) { const isEdit = mode === 'edit'; const today = new Date().toISOString().slice(0, 10); const isExpense = isEdit ? entry.amount < 0 : true; const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => `` ).join(''); const content = `
`; openSharedModal({ title: isEdit ? t('budget.editEntry') : t('budget.newEntry'), content, size: 'sm', onSave(panel) { let currentType = isExpense ? 'expense' : 'income'; panel.querySelector('#type-expense').addEventListener('click', () => { currentType = 'expense'; panel.querySelector('#type-expense').classList.add('amount-type-btn--active'); panel.querySelector('#type-income').classList.remove('amount-type-btn--active'); }); panel.querySelector('#type-income').addEventListener('click', () => { currentType = 'income'; panel.querySelector('#type-income').classList.add('amount-type-btn--active'); panel.querySelector('#type-expense').classList.remove('amount-type-btn--active'); }); panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return; closeModal(); await deleteEntry(entry.id); }); panel.querySelector('#bm-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#bm-save'); const title = panel.querySelector('#bm-title').value.trim(); const absVal = parseFloat(panel.querySelector('#bm-amount').value); const category = panel.querySelector('#bm-category').value; const date = panel.querySelector('#bm-date').value; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; if (!title) { window.oikos?.showToast(t('common.titleRequired'), 'error'); return; } if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; } if (!date) { window.oikos?.showToast(t('budget.dateRequired'), '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; } const sumRes = await api.get(`/budget/summary?month=${state.month}`); state.summary = sumRes.data; closeModal(); renderBody(); window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('common.add'); } }); }, }); } // -------------------------------------------------------- // Eintrag löschen // -------------------------------------------------------- async function deleteEntry(id) { if (!confirm(t('budget.deleteConfirm'))) 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(); vibrate([30, 50, 30]); window.oikos?.showToast(t('budget.deletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } // -------------------------------------------------------- // Hilfsfunktion // --------------------------------------------------------