/** * 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, confirmModal } 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 CATEGORY_I18N = () => ({ // Expense categories housing: t('budget.catHousing'), food: t('budget.catFood'), transport: t('budget.catTransport'), personal_health: t('budget.catPersonalHealth'), leisure: t('budget.catLeisure'), shopping_clothing: t('budget.catShoppingClothing'), education: t('budget.catEducation'), financial_other: t('budget.catFinancialOther'), // Income categories 'Erwerbseinkommen': t('budget.catEarnedIncome'), 'Kapitalerträge': t('budget.catInvestmentIncome'), 'Geschenke & Transfers': t('budget.catTransferGiftIncome'), 'Sozialleistungen': t('budget.catGovernmentBenefits'), 'Sonstiges Einkommen': t('budget.catOtherIncome'), }); const SUBCATEGORY_I18N = () => ({ rent_mortgage: t('budget.subcatRentMortgage'), condominium: t('budget.subcatCondominium'), utilities: t('budget.subcatUtilities'), internet_tv_phone: t('budget.subcatInternetTvPhone'), renovation_maintenance: t('budget.subcatRenovationMaintenance'), cleaning: t('budget.subcatCleaning'), groceries: t('budget.subcatGroceries'), restaurants_bars: t('budget.subcatRestaurantsBars'), snacks_fast_food: t('budget.subcatSnacksFastFood'), bakery: t('budget.subcatBakery'), fuel: t('budget.subcatFuel'), parking_tolls: t('budget.subcatParkingTolls'), public_transport: t('budget.subcatPublicTransport'), apps_taxi: t('budget.subcatAppsTaxi'), maintenance_insurance: t('budget.subcatMaintenanceInsurance'), pharmacy: t('budget.subcatPharmacy'), health_insurance: t('budget.subcatHealthInsurance'), gym_sports: t('budget.subcatGymSports'), beauty_cosmetics: t('budget.subcatBeautyCosmetics'), travel: t('budget.subcatTravel'), streaming: t('budget.subcatStreaming'), events: t('budget.subcatEvents'), hobbies: t('budget.subcatHobbies'), clothes_shoes: t('budget.subcatClothesShoes'), electronics: t('budget.subcatElectronics'), gifts: t('budget.subcatGifts'), courses_college: t('budget.subcatCoursesCollege'), school_supplies: t('budget.subcatSchoolSupplies'), languages: t('budget.subcatLanguages'), loans_interest: t('budget.subcatLoansInterest'), bank_fees: t('budget.subcatBankFees'), insurance_other: t('budget.subcatInsuranceOther'), investments: t('budget.subcatInvestments'), taxes: t('budget.subcatTaxes'), }); function categoryLabel(category) { const item = typeof category === 'object' ? category : [...expenseCategories(), ...incomeCategories()].find((c) => c.key === category); const key = item?.key ?? category; const name = item?.name ?? category; return CATEGORY_I18N()[key] ?? name; } function subcategoryLabel(subcategory) { const item = typeof subcategory === 'object' ? subcategory : Object.values(state.meta.expenseSubcategories ?? {}).flat().find((s) => s.key === subcategory); const key = item?.key ?? subcategory; const name = item?.name ?? subcategory; return SUBCATEGORY_I18N()[key] ?? name; } function expenseCategories() { return state.meta.expenseCategories ?? []; } function incomeCategories() { return state.meta.incomeCategories ?? []; } function getSubcategories(category) { return state.meta.expenseSubcategories?.[category] || []; } function defaultSubcategory(category) { return getSubcategories(category)[0]?.key || ''; } function defaultCategory(type) { const cats = type === 'income' ? incomeCategories() : expenseCategories(); return cats[0]?.key || ''; } 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 currency: 'EUR', meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }, }; let _container = null; // -------------------------------------------------------- // Formatierung // -------------------------------------------------------- function formatAmount(n) { return new Intl.NumberFormat(getLocale(), { style: 'currency', currency: state.currency }).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'); } } async function loadBudgetMeta() { try { const res = await api.get('/budget/meta'); state.meta = { expenseCategories: res.data?.expenseCategories ?? [], incomeCategories: res.data?.incomeCategories ?? [], expenseSubcategories: res.data?.expenseSubcategories ?? {}, }; } catch (err) { console.error('[Budget] meta Fehler:', err); state.meta = { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }; window.oikos?.showToast(t('budget.metaLoadError'), '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')}`; try { const [prefsRes] = await Promise.all([ api.get('/preferences'), loadBudgetMeta(), ]); state.currency = prefsRes.data?.currency ?? 'EUR'; } catch (_) { /* Fallback auf EUR */ } 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(categoryLabel(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 ? ' ↩' : ''); const categoryMeta = isIncome || !e.subcategory ? categoryLabel(e.category) : `${categoryLabel(e.category)} · ${subcategoryLabel(e.subcategory)}`; 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 initialCats = isExpense ? expenseCategories() : incomeCategories(); const catOpts = initialCats.map((c) => `` ).join(''); const initialCategory = isEdit ? entry.category : initialCats[0]?.key; const initialSubcategory = isEdit ? entry.subcategory : defaultSubcategory(initialCategory); const subcatOpts = getSubcategories(initialCategory).map((s) => `` ).join(''); const content = `
`; openSharedModal({ title: isEdit ? t('budget.editEntry') : t('budget.newEntry'), content, size: 'sm', onSave(panel) { let currentType = isExpense ? 'expense' : 'income'; const updateCategoryOptions = (preferredCategory = '') => { const cats = currentType === 'income' ? incomeCategories() : expenseCategories(); const catSelect = panel.querySelector('#bm-category'); const currentValue = preferredCategory || catSelect.value; const options = cats.map((c) => { const opt = document.createElement('option'); opt.value = c.key; opt.textContent = categoryLabel(c); opt.selected = currentValue === c.key; return opt; }); catSelect.replaceChildren(...options); if (!cats.some((c) => c.key === catSelect.value)) catSelect.value = cats[0]?.key || ''; updateSubcategoryOptions(); }; const updateSubcategoryOptions = (preferredSubcategory = '') => { const catSelect = panel.querySelector('#bm-category'); const subcatGroup = panel.querySelector('#bm-subcategory-group'); const subcatSelect = panel.querySelector('#bm-subcategory'); const subcategories = currentType === 'expense' ? getSubcategories(catSelect.value) : []; const currentValue = preferredSubcategory || subcatSelect.value; subcatGroup.hidden = currentType !== 'expense'; subcatSelect.replaceChildren(...subcategories.map((s) => { const opt = document.createElement('option'); opt.value = s.key; opt.textContent = subcategoryLabel(s); opt.selected = currentValue === s.key; return opt; })); if (subcategories.length && !subcategories.some((s) => s.key === subcatSelect.value)) { subcatSelect.value = subcategories[0].key; } }; const addCategory = async () => { const name = window.prompt(t('budget.newCategoryPrompt')); if (!name?.trim()) return; try { const res = await api.post('/budget/categories', { name: name.trim(), type: currentType }); await loadBudgetMeta(); updateCategoryOptions(res.data.key); window.oikos?.showToast(t('budget.categoryAddedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } }; const addSubcategory = async () => { if (currentType !== 'expense') return; const category = panel.querySelector('#bm-category').value; if (!category) return; const name = window.prompt(t('budget.newSubcategoryPrompt')); if (!name?.trim()) return; try { const res = await api.post(`/budget/categories/${encodeURIComponent(category)}/subcategories`, { name: name.trim() }); await loadBudgetMeta(); updateSubcategoryOptions(res.data.key); window.oikos?.showToast(t('budget.subcategoryAddedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } }; 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'); updateCategoryOptions(); }); 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'); updateCategoryOptions(); }); panel.querySelector('#bm-category').addEventListener('change', () => updateSubcategoryOptions()); panel.querySelector('#bm-add-category').addEventListener('click', addCategory); panel.querySelector('#bm-add-subcategory').addEventListener('click', addSubcategory); panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { 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 subcategory = currentType === 'expense' ? panel.querySelector('#bm-subcategory').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, subcategory, 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 (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) 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 // --------------------------------------------------------