/**
* 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 EXPENSE_CATEGORIES = [
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
];
const INCOME_CATEGORIES = [
'Erwerbseinkommen', 'Kapitalerträge', 'Geschenke & Transfers',
'Sozialleistungen', 'Sonstiges Einkommen',
];
const CATEGORIES = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES];
const CATEGORY_LABELS = () => ({
// Expense categories
'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'),
// 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'),
});
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',
};
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');
}
}
// --------------------------------------------------------
// 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 api.get('/preferences');
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)}
` : ''}
`;
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(CATEGORY_LABELS()[c.category] ?? 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)}
${date} · ${esc(CATEGORY_LABELS()[e.category] ?? e.category)}${recurTag}
${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 initialCats = isExpense ? EXPENSE_CATEGORIES : INCOME_CATEGORIES;
const catOpts = initialCats.map((c) =>
``
).join('');
const content = `
`;
openSharedModal({
title: isEdit ? t('budget.editEntry') : t('budget.newEntry'),
content,
size: 'sm',
onSave(panel) {
let currentType = isExpense ? 'expense' : 'income';
const updateCategoryOptions = () => {
const catLabels = CATEGORY_LABELS();
const cats = currentType === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
const catSelect = panel.querySelector('#bm-category');
const currentValue = catSelect.value;
const options = cats.map((c) => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = catLabels[c] || c;
opt.selected = currentValue === c;
return opt;
});
catSelect.replaceChildren(...options);
};
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-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 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 (!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
// --------------------------------------------------------