/** * 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 loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }, activeTab: 'budget', loanFilterId: null, loanStatusFilter: 'active', 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')}`; } function setHtml(element, html) { element.replaceChildren(); element.insertAdjacentHTML('afterbegin', html); } // -------------------------------------------------------- // API // -------------------------------------------------------- async function loadMonth(month) { const prevMonth = addMonths(month, -1); try { const [entriesRes, summaryRes, prevSummaryRes, loansRes] = await Promise.all([ api.get(`/budget?month=${month}`), api.get(`/budget/summary?month=${month}`), api.get(`/budget/summary?month=${prevMonth}`), api.get('/budget/loans'), ]); state.month = month; state.entries = entriesRes.data; state.summary = summaryRes.data; state.prevSummary = prevSummaryRes.data; state.loans = loansRes.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; state.loans = { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }; 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 */ } setHtml(container, `

${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); _container.querySelectorAll('.budget-tab').forEach((tab) => { tab.addEventListener('click', () => { state.activeTab = tab.dataset.tab; renderBody(); }); }); 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; updateTabs(); if (state.activeTab === 'loans') { setHtml(body, renderLoansPage()); wireLoansPage(); if (window.lucide) lucide.createIcons(); return; } 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) : ''; setHtml(body, `
${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(); _container.querySelector('#empty-cta-budget')?.addEventListener('click', () => { document.querySelector('.page-fab')?.click(); }); 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 updateTabs() { _container.querySelectorAll('.budget-tab').forEach((tab) => { const active = tab.dataset.tab === state.activeTab; tab.classList.toggle('budget-tab--active', active); tab.setAttribute('aria-selected', String(active)); }); } 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')}

${t('emptyHint.budget')}

`; } 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(''); } function renderLoansDashboard() { const loans = state.loans?.loans ?? []; if (!loans.length) return ''; const summary = state.loans?.summary ?? {}; const visibleLoans = filteredLoans(); return `
${t('budget.loansTitle')}
${t('budget.loansSummary', { count: summary.active_count ?? 0, amount: formatAmount(summary.remaining_amount ?? 0), })}
${state.loanFilterId ? `
${esc(activeLoanLabel())}
` : ''}
${state.loanFilterId ? ` ` : ''}
${t('budget.loanRemainingAmount')} ${formatAmount(summary.remaining_amount ?? 0)}
${t('budget.loanRemainingInstallments')} ${summary.remaining_installments ?? 0}
${t('budget.loanPaidAmount')} ${formatAmount(summary.paid_amount ?? 0)}
${visibleLoans.length ? `
${visibleLoans.map(renderLoanCard).join('')}
` : `
${t('budget.loansEmpty')}
`} ${renderLoanTransactions(visibleLoans)}
`; } function filteredLoans() { const loans = state.loans?.loans ?? []; return loans.filter((loan) => { const matchesStatus = state.loanStatusFilter === 'all' || loan.status === state.loanStatusFilter; const matchesLoan = !state.loanFilterId || loan.id === state.loanFilterId; return matchesStatus && matchesLoan; }); } function activeLoanLabel() { const loan = state.loans.loans.find((item) => item.id === state.loanFilterId); return loan ? t('budget.loanFilterActive', { title: loan.title }) : ''; } function loanPaymentsFor(loans) { return loans.flatMap((loan) => (loan.payments ?? []).map((payment) => ({ ...payment, loan }))) .sort((a, b) => new Date(b.paid_date) - new Date(a.paid_date) || b.installment_number - a.installment_number); } function renderLoanTransactions(loans) { const payments = loanPaymentsFor(loans); if (!payments.length) return ''; return `
${t('budget.loanTransactions')}
${payments.map(({ loan, ...payment }) => renderLoanPaymentEntry(loan, payment)).join('')}
`; } function loanPaymentToEntry(loan, payment) { if (!payment.budget_entry_id) return null; return { id: payment.budget_entry_id, title: payment.entry_title || `Loan repayment: ${loan.borrower}`, amount: Number(payment.amount || 0), category: payment.entry_category || 'Geschenke & Transfers', subcategory: payment.entry_subcategory || '', date: payment.paid_date, is_recurring: payment.entry_is_recurring || 0, recurrence_parent_id: payment.entry_recurrence_parent_id || null, }; } function renderLoanPaymentEntry(loan, payment) { const entry = loanPaymentToEntry(loan, payment); const meta = `${formatEntryDate(payment.paid_date)} · ${esc(loan.title)} · ${t('budget.loanInstallmentNumber', { number: payment.installment_number, total: loan.installment_count, })}`; return `
${esc(payment.entry_title || t('budget.loanPaymentTitle', { borrower: loan.borrower }))}
+${formatAmount(payment.amount)}
${entry ? ` ` : ''}
`; } function renderLoansPage() { const loans = state.loans?.loans ?? []; if (!loans.length) { return `
${t('budget.loansEmpty')}
${t('budget.loansEmptyDescription')}
`; } return `
${renderLoansDashboard()}
`; } function wireLoansPage() { _container.querySelector('#budget-empty-loan')?.addEventListener('click', () => openBudgetModal({ mode: 'create', initialType: 'loan' })); _container.querySelector('#budget-clear-loan-filter')?.addEventListener('click', () => { state.loanFilterId = null; renderBody(); }); _container.querySelectorAll('[data-loan-status]').forEach((btn) => { btn.addEventListener('click', () => { state.loanStatusFilter = btn.dataset.loanStatus; renderBody(); }); }); _container.querySelectorAll('.budget-loan-card[data-loan-id]').forEach((card) => { card.addEventListener('click', (event) => { if (event.target.closest('button, a')) return; const loan = state.loans.loans.find((item) => item.id === parseInt(card.dataset.loanId, 10)); if (loan) openLoanReport(loan); }); }); _container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => { btn.addEventListener('click', async () => { await markLoanPayment(parseInt(btn.dataset.id, 10)); }); }); _container.querySelectorAll('[data-action="loan-edit"]').forEach((btn) => { btn.addEventListener('click', () => { const loan = state.loans.loans.find((item) => item.id === parseInt(btn.dataset.id, 10)); if (loan) openLoanModal(loan); }); }); _container.querySelectorAll('[data-action="loan-delete"]').forEach((btn) => { btn.addEventListener('click', async () => { await deleteLoan(parseInt(btn.dataset.id, 10)); }); }); _container.querySelectorAll('[data-action="loan-filter"]').forEach((btn) => { btn.addEventListener('click', () => { const id = parseInt(btn.dataset.id, 10); state.loanFilterId = state.loanFilterId === id ? null : id; renderBody(); }); }); _container.querySelectorAll('[data-action="loan-payment-edit"]').forEach((btn) => { btn.addEventListener('click', () => { const loan = state.loans.loans.find((item) => item.id === parseInt(btn.dataset.loanId, 10)); const payment = loan?.payments?.find((item) => item.id === parseInt(btn.dataset.paymentId, 10)); const entry = loan && payment ? loanPaymentToEntry(loan, payment) : null; if (entry) openBudgetModal({ mode: 'edit', entry }); }); }); _container.querySelectorAll('[data-action="loan-payment-delete"]').forEach((btn) => { btn.addEventListener('click', async () => { await deleteLoanPayment(parseInt(btn.dataset.loanId, 10), parseInt(btn.dataset.paymentId, 10)); }); }); } function openLoanReport(loan) { const payments = (loan.payments ?? []).slice() .sort((a, b) => new Date(b.paid_date) - new Date(a.paid_date) || b.installment_number - a.installment_number); const content = `
${esc(loan.borrower)}
${esc(loan.title)}
${loan.status === 'paid' ? t('budget.loanStatusPaid') : t('budget.loanStatusActive')}
${t('budget.loanAmountLabel')}${formatAmount(loan.total_amount)}
${t('budget.loanRemainingAmount')}${formatAmount(loan.remaining_amount)}
${t('budget.loanPaidAmount')}${formatAmount(loan.paid_amount)}
${t('budget.loanRemainingInstallments')}${loan.remaining_installments}
${t('budget.loanTransactions')}
${payments.length ? `
${payments.map((payment) => `
${t('budget.loanInstallmentNumber', { number: payment.installment_number, total: loan.installment_count })} ${formatEntryDate(payment.paid_date)}
${formatAmount(payment.amount)}
`).join('')}
` : `
${t('budget.loanNoTransactions')}
`}
`; openSharedModal({ title: t('budget.loanReportTitle'), content, size: 'md', onSave(panel) { panel.querySelector('#loan-report-close')?.addEventListener('click', closeModal); }, }); } function renderLoanCard(loan) { const paidPct = Math.min(100, Math.round((loan.paid_amount / loan.total_amount) * 100)); const nextDue = loan.next_due_month ? formatMonthLabel(loan.next_due_month) : t('budget.loanPaidStatus'); const payDisabled = loan.remaining_installments <= 0 ? 'disabled' : ''; return `
${esc(loan.title)}
${esc(loan.borrower)} · ${t('budget.loanInstallmentMeta', { paid: loan.paid_installments, total: loan.installment_count, })}
${formatAmount(loan.remaining_amount)} ${t('budget.loanRemainingOf', { total: formatAmount(loan.total_amount) })}
`; } /** * 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(dateStr); } // -------------------------------------------------------- // Modal // -------------------------------------------------------- function openBudgetModal({ mode, entry = null, initialType = '' }) { const isEdit = mode === 'edit'; const today = new Date().toISOString().slice(0, 10); const todayMonth = today.slice(0, 7); 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 = `
${!isEdit ? `` : ''}
`; openSharedModal({ title: isEdit ? t('budget.editEntry') : t('budget.newEntry'), content, size: 'sm', onSave(panel) { let currentType = !isEdit && initialType === 'loan' ? 'loan' : (isExpense ? 'expense' : 'income'); const setType = (type) => { currentType = type; panel.querySelector('#type-expense').classList.toggle('amount-type-btn--active', type === 'expense'); panel.querySelector('#type-income').classList.toggle('amount-type-btn--active', type === 'income'); panel.querySelector('#type-loan')?.classList.toggle('amount-type-btn--active', type === 'loan'); panel.querySelectorAll('.js-entry-field').forEach((el) => { el.hidden = type === 'loan'; }); panel.querySelector('#bm-loan-fields').hidden = type !== 'loan'; panel.querySelector('#bm-save').textContent = type === 'loan' ? t('budget.createLoan') : (isEdit ? t('common.save') : t('common.add')); if (type !== 'loan') updateCategoryOptions(); }; 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 = await requestNameInPanel(panel, { title: t('budget.newCategoryTitle'), label: t('budget.newCategoryPrompt'), placeholder: t('budget.newCategoryPlaceholder'), }); 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 = await requestNameInPanel(panel, { title: t('budget.newSubcategoryTitle'), label: t('budget.newSubcategoryPrompt'), placeholder: t('budget.newSubcategoryPlaceholder'), }); 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', () => { setType('expense'); }); panel.querySelector('#type-income').addEventListener('click', () => { setType('income'); }); panel.querySelector('#type-loan')?.addEventListener('click', () => { setType('loan'); }); 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({ force: true }); await deleteEntry(entry.id); }); panel.querySelector('#bm-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#bm-save'); if (currentType === 'loan') { await saveLoanFromPanel(panel, saveBtn, { closeAfterSave: true }); return; } 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('calendar.invalidDate'), '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; } await loadMonth(state.month); closeModal({ force: true }); 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'); } }); setType(currentType); }, }); } function requestNameInPanel(panel, { title, label, placeholder }) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'budget-inline-modal'; setHtml(overlay, ` `); panel.append(overlay); if (window.lucide) lucide.createIcons(); const input = overlay.querySelector('#budget-inline-name'); const cleanup = (value = '') => { overlay.remove(); resolve(value); }; overlay.querySelectorAll('[data-action="inline-cancel"]').forEach((btn) => { btn.addEventListener('click', () => cleanup('')); }); overlay.querySelector('[data-action="inline-save"]').addEventListener('click', () => { cleanup(input.value.trim()); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') cleanup(input.value.trim()); if (e.key === 'Escape') cleanup(''); }); input.focus(); }); } async function saveLoanFromPanel(panel, saveBtn, { loan = null, closeAfterSave = false } = {}) { const isEdit = Boolean(loan); const borrower = panel.querySelector('#lm-borrower').value.trim(); const title = panel.querySelector('#lm-title').value.trim() || borrower; const total_amount = parseFloat(panel.querySelector('#lm-amount').value); const installment_count = parseInt(panel.querySelector('#lm-installments').value, 10); const start_month = panel.querySelector('#lm-start').value; const notes = panel.querySelector('#lm-notes').value.trim(); if (!borrower) { window.oikos?.showToast(t('budget.loanBorrowerRequired'), 'error'); return; } if (isNaN(total_amount) || total_amount <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; } if (!Number.isInteger(installment_count) || installment_count < 1) { window.oikos?.showToast(t('budget.loanInstallmentsRequired'), 'error'); return; } if (!/^\d{4}-\d{2}$/.test(start_month)) { window.oikos?.showToast(t('budget.loanStartMonthRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '...'; try { const body = { borrower, title, total_amount, installment_count, start_month, notes }; if (isEdit) { await api.put(`/budget/loans/${loan.id}`, body); } else { await api.post('/budget/loans', body); } await loadMonth(state.month); if (closeAfterSave) closeModal({ force: true }); renderBody(); window.oikos?.showToast(isEdit ? t('budget.loanSavedToast') : t('budget.loanAddedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('budget.createLoan'); } } function openLoanModal(loan = null) { const isEdit = Boolean(loan); const todayMonth = new Date().toISOString().slice(0, 7); const content = `
`; openSharedModal({ title: isEdit ? t('budget.editLoan') : t('budget.newLoan'), content, size: 'sm', onSave(panel) { panel.querySelector('#lm-cancel').addEventListener('click', closeModal); panel.querySelector('#lm-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#lm-save'); await saveLoanFromPanel(panel, saveBtn, { loan, closeAfterSave: true }); }); }, }); } async function markLoanPayment(id) { const loan = state.loans.loans.find((item) => item.id === id); if (!loan?.next_installment_number) return; const today = new Date().toISOString().slice(0, 10); try { await api.post(`/budget/loans/${id}/payments`, { installment_number: loan.next_installment_number, amount: loan.next_installment_number === loan.installment_count ? loan.remaining_amount : Math.min(loan.installment_amount, loan.remaining_amount), paid_date: today, }); await loadMonth(state.month); renderBody(); window.oikos?.showToast(t('budget.loanPaymentAddedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } async function deleteLoan(id) { const loan = state.loans.loans.find((item) => item.id === id); if (!loan) return; if (!await confirmModal(t('budget.deleteLoanConfirm', { title: loan.title }), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/budget/loans/${id}`); await loadMonth(state.month); renderBody(); window.oikos?.showToast(t('budget.loanDeletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } async function deleteLoanPayment(loanId, paymentId) { if (!await confirmModal(t('budget.deleteLoanPaymentConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; try { await api.delete(`/budget/loans/${loanId}/payments/${paymentId}`); await loadMonth(state.month); renderBody(); window.oikos?.showToast(t('budget.deletedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); } } // -------------------------------------------------------- // Eintrag löschen // -------------------------------------------------------- async function deleteEntry(id) { const entry = state.entries.find((e) => e.id === id); state.entries = state.entries.filter((e) => e.id !== id); renderBody(); vibrate([30, 50, 30]); let undone = false; window.oikos?.showToast(t('budget.deletedToast'), 'default', 5000, () => { undone = true; if (entry) { state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date)); renderBody(); } }); setTimeout(async () => { if (undone) return; try { await api.delete(`/budget/${id}`); await loadMonth(state.month); renderBody(); } catch (err) { if (entry) { state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date)); renderBody(); } window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); } }, 5000); } // -------------------------------------------------------- // Hilfsfunktion // --------------------------------------------------------