/**
* 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)}
` : ''}
`);
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)}
${date} · ${esc(categoryMeta)}${recurTag}
${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.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 }))}
${meta}
+${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.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
// --------------------------------------------------------