diff --git a/public/locales/ar.json b/public/locales/ar.json
index 12a0bef..bb06e0e 100644
--- a/public/locales/ar.json
+++ b/public/locales/ar.json
@@ -597,7 +597,19 @@
"loanSavedToast": "تم حفظ القرض",
"loanDeletedToast": "تم حذف القرض",
"loanPaymentAddedToast": "تم تسجيل الدفع",
- "typeLoan": "قرض"
+ "typeLoan": "قرض",
+ "tabsLabel": "أقسام الميزانية",
+ "budgetTab": "الميزانية",
+ "loansTab": "القروض",
+ "filteredTransactions": "المعاملات المصفاة",
+ "clearLoanFilter": "مسح الفلتر",
+ "loanFilterActive": "القرض: {{title}}",
+ "filterLoanTransactions": "عرض معاملات هذا القرض",
+ "loansEmptyDescription": "أنشئ قرضًا من زر + واختر قرض.",
+ "newCategoryTitle": "فئة جديدة",
+ "newCategoryPlaceholder": "اسم الفئة",
+ "newSubcategoryTitle": "فئة فرعية جديدة",
+ "newSubcategoryPlaceholder": "اسم الفئة الفرعية"
},
"settings": {
"title": "الإعدادات",
diff --git a/public/locales/de.json b/public/locales/de.json
index b31a104..8600a5d 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -622,7 +622,19 @@
"loanSavedToast": "Darlehen gespeichert",
"loanDeletedToast": "Darlehen gelöscht",
"loanPaymentAddedToast": "Zahlung erfasst",
- "typeLoan": "Darlehen"
+ "typeLoan": "Darlehen",
+ "tabsLabel": "Budgetbereiche",
+ "budgetTab": "Budget",
+ "loansTab": "Darlehen",
+ "filteredTransactions": "Gefilterte Transaktionen",
+ "clearLoanFilter": "Filter löschen",
+ "loanFilterActive": "Darlehen: {{title}}",
+ "filterLoanTransactions": "Transaktionen dieses Darlehens anzeigen",
+ "loansEmptyDescription": "Erstelle ein Darlehen über die +-Schaltfläche und wähle Darlehen.",
+ "newCategoryTitle": "Neue Kategorie",
+ "newCategoryPlaceholder": "Kategoriename",
+ "newSubcategoryTitle": "Neue Unterkategorie",
+ "newSubcategoryPlaceholder": "Name der Unterkategorie"
},
"settings": {
"title": "Einstellungen",
diff --git a/public/locales/el.json b/public/locales/el.json
index dc489f3..84cce00 100644
--- a/public/locales/el.json
+++ b/public/locales/el.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Το δάνειο αποθηκεύτηκε",
"loanDeletedToast": "Το δάνειο διαγράφηκε",
"loanPaymentAddedToast": "Η πληρωμή καταγράφηκε",
- "typeLoan": "Δάνειο"
+ "typeLoan": "Δάνειο",
+ "tabsLabel": "Ενότητες προϋπολογισμού",
+ "budgetTab": "Προϋπολογισμός",
+ "loansTab": "Δάνεια",
+ "filteredTransactions": "Φιλτραρισμένες συναλλαγές",
+ "clearLoanFilter": "Καθαρισμός φίλτρου",
+ "loanFilterActive": "Δάνειο: {{title}}",
+ "filterLoanTransactions": "Εμφάνιση συναλλαγών αυτού του δανείου",
+ "loansEmptyDescription": "Δημιουργήστε δάνειο από το κουμπί + και επιλέξτε Δάνειο.",
+ "newCategoryTitle": "Νέα κατηγορία",
+ "newCategoryPlaceholder": "Όνομα κατηγορίας",
+ "newSubcategoryTitle": "Νέα υποκατηγορία",
+ "newSubcategoryPlaceholder": "Όνομα υποκατηγορίας"
},
"settings": {
"title": "Ρυθμίσεις",
diff --git a/public/locales/en.json b/public/locales/en.json
index e302cb3..0e65b43 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Loan saved",
"loanDeletedToast": "Loan deleted",
"loanPaymentAddedToast": "Payment recorded",
- "typeLoan": "Loan"
+ "typeLoan": "Loan",
+ "tabsLabel": "Budget sections",
+ "budgetTab": "Budget",
+ "loansTab": "Loans",
+ "filteredTransactions": "Filtered transactions",
+ "clearLoanFilter": "Clear filter",
+ "loanFilterActive": "Loan: {{title}}",
+ "filterLoanTransactions": "Show transactions for this loan",
+ "loansEmptyDescription": "Create a loan from the + button and choose Loan.",
+ "newCategoryTitle": "New category",
+ "newCategoryPlaceholder": "Category name",
+ "newSubcategoryTitle": "New subcategory",
+ "newSubcategoryPlaceholder": "Subcategory name"
},
"settings": {
"title": "Settings",
diff --git a/public/locales/es.json b/public/locales/es.json
index 42a3eae..00bdbc1 100644
--- a/public/locales/es.json
+++ b/public/locales/es.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Préstamo guardado",
"loanDeletedToast": "Préstamo eliminado",
"loanPaymentAddedToast": "Pago registrado",
- "typeLoan": "Préstamo"
+ "typeLoan": "Préstamo",
+ "tabsLabel": "Secciones del presupuesto",
+ "budgetTab": "Presupuesto",
+ "loansTab": "Préstamos",
+ "filteredTransactions": "Transacciones filtradas",
+ "clearLoanFilter": "Limpiar filtro",
+ "loanFilterActive": "Préstamo: {{title}}",
+ "filterLoanTransactions": "Mostrar transacciones de este préstamo",
+ "loansEmptyDescription": "Crea un préstamo con el botón + y elige Préstamo.",
+ "newCategoryTitle": "Nueva categoría",
+ "newCategoryPlaceholder": "Nombre de la categoría",
+ "newSubcategoryTitle": "Nueva subcategoría",
+ "newSubcategoryPlaceholder": "Nombre de la subcategoría"
},
"settings": {
"title": "Ajustes",
diff --git a/public/locales/fr.json b/public/locales/fr.json
index 2375324..bd02cdf 100644
--- a/public/locales/fr.json
+++ b/public/locales/fr.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Prêt enregistré",
"loanDeletedToast": "Prêt supprimé",
"loanPaymentAddedToast": "Paiement enregistré",
- "typeLoan": "Prêt"
+ "typeLoan": "Prêt",
+ "tabsLabel": "Sections du budget",
+ "budgetTab": "Budget",
+ "loansTab": "Prêts",
+ "filteredTransactions": "Transactions filtrées",
+ "clearLoanFilter": "Effacer le filtre",
+ "loanFilterActive": "Prêt : {{title}}",
+ "filterLoanTransactions": "Afficher les transactions de ce prêt",
+ "loansEmptyDescription": "Créez un prêt avec le bouton + puis choisissez Prêt.",
+ "newCategoryTitle": "Nouvelle catégorie",
+ "newCategoryPlaceholder": "Nom de la catégorie",
+ "newSubcategoryTitle": "Nouvelle sous-catégorie",
+ "newSubcategoryPlaceholder": "Nom de la sous-catégorie"
},
"settings": {
"title": "Paramètres",
diff --git a/public/locales/hi.json b/public/locales/hi.json
index a71d971..12d81b9 100644
--- a/public/locales/hi.json
+++ b/public/locales/hi.json
@@ -597,7 +597,19 @@
"loanSavedToast": "उधार सहेजा गया",
"loanDeletedToast": "उधार हटाया गया",
"loanPaymentAddedToast": "भुगतान दर्ज किया गया",
- "typeLoan": "उधार"
+ "typeLoan": "उधार",
+ "tabsLabel": "बजट अनुभाग",
+ "budgetTab": "बजट",
+ "loansTab": "उधार",
+ "filteredTransactions": "फ़िल्टर किए गए लेन-देन",
+ "clearLoanFilter": "फ़िल्टर हटाएं",
+ "loanFilterActive": "उधार: {{title}}",
+ "filterLoanTransactions": "इस उधार के लेन-देन दिखाएं",
+ "loansEmptyDescription": "+ बटन से उधार चुनकर नया उधार बनाएं।",
+ "newCategoryTitle": "नई श्रेणी",
+ "newCategoryPlaceholder": "श्रेणी का नाम",
+ "newSubcategoryTitle": "नई उपश्रेणी",
+ "newSubcategoryPlaceholder": "उपश्रेणी का नाम"
},
"settings": {
"title": "सेटिंग्स",
diff --git a/public/locales/it.json b/public/locales/it.json
index 6d52c9d..c5925a6 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Prestito salvato",
"loanDeletedToast": "Prestito eliminato",
"loanPaymentAddedToast": "Pagamento registrato",
- "typeLoan": "Prestito"
+ "typeLoan": "Prestito",
+ "tabsLabel": "Sezioni del bilancio",
+ "budgetTab": "Bilancio",
+ "loansTab": "Prestiti",
+ "filteredTransactions": "Movimenti filtrati",
+ "clearLoanFilter": "Cancella filtro",
+ "loanFilterActive": "Prestito: {{title}}",
+ "filterLoanTransactions": "Mostra i movimenti di questo prestito",
+ "loansEmptyDescription": "Crea un prestito dal pulsante + e scegli Prestito.",
+ "newCategoryTitle": "Nuova categoria",
+ "newCategoryPlaceholder": "Nome categoria",
+ "newSubcategoryTitle": "Nuova sottocategoria",
+ "newSubcategoryPlaceholder": "Nome sottocategoria"
},
"settings": {
"title": "Impostazioni",
diff --git a/public/locales/ja.json b/public/locales/ja.json
index 4d8d440..e9936dd 100644
--- a/public/locales/ja.json
+++ b/public/locales/ja.json
@@ -597,7 +597,19 @@
"loanSavedToast": "貸付を保存しました",
"loanDeletedToast": "貸付を削除しました",
"loanPaymentAddedToast": "返済を記録しました",
- "typeLoan": "貸付"
+ "typeLoan": "貸付",
+ "tabsLabel": "予算セクション",
+ "budgetTab": "予算",
+ "loansTab": "貸付",
+ "filteredTransactions": "絞り込み済み取引",
+ "clearLoanFilter": "フィルター解除",
+ "loanFilterActive": "貸付:{{title}}",
+ "filterLoanTransactions": "この貸付の取引を表示",
+ "loansEmptyDescription": "+ ボタンから貸付を選んで作成します。",
+ "newCategoryTitle": "新しいカテゴリ",
+ "newCategoryPlaceholder": "カテゴリ名",
+ "newSubcategoryTitle": "新しいサブカテゴリ",
+ "newSubcategoryPlaceholder": "サブカテゴリ名"
},
"settings": {
"title": "設定",
diff --git a/public/locales/pt.json b/public/locales/pt.json
index 065fc1f..7e8130b 100644
--- a/public/locales/pt.json
+++ b/public/locales/pt.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Empréstimo salvo",
"loanDeletedToast": "Empréstimo excluído",
"loanPaymentAddedToast": "Pagamento registrado",
- "typeLoan": "Empréstimo"
+ "typeLoan": "Empréstimo",
+ "tabsLabel": "Seções do orçamento",
+ "budgetTab": "Orçamento",
+ "loansTab": "Empréstimos",
+ "filteredTransactions": "Transações filtradas",
+ "clearLoanFilter": "Limpar filtro",
+ "loanFilterActive": "Empréstimo: {{title}}",
+ "filterLoanTransactions": "Mostrar transações deste empréstimo",
+ "loansEmptyDescription": "Crie um empréstimo pelo botão + e escolha Empréstimo.",
+ "newCategoryTitle": "Nova categoria",
+ "newCategoryPlaceholder": "Nome da categoria",
+ "newSubcategoryTitle": "Nova subcategoria",
+ "newSubcategoryPlaceholder": "Nome da subcategoria"
},
"settings": {
"title": "Configurações",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 464f901..e82839a 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Займ сохранён",
"loanDeletedToast": "Займ удалён",
"loanPaymentAddedToast": "Платёж записан",
- "typeLoan": "Займ"
+ "typeLoan": "Займ",
+ "tabsLabel": "Разделы бюджета",
+ "budgetTab": "Бюджет",
+ "loansTab": "Займы",
+ "filteredTransactions": "Отфильтрованные операции",
+ "clearLoanFilter": "Сбросить фильтр",
+ "loanFilterActive": "Займ: {{title}}",
+ "filterLoanTransactions": "Показать операции этого займа",
+ "loansEmptyDescription": "Создайте займ кнопкой + и выберите Займ.",
+ "newCategoryTitle": "Новая категория",
+ "newCategoryPlaceholder": "Название категории",
+ "newSubcategoryTitle": "Новая подкатегория",
+ "newSubcategoryPlaceholder": "Название подкатегории"
},
"settings": {
"title": "Настройки",
diff --git a/public/locales/sv.json b/public/locales/sv.json
index 8bf7ca4..2433464 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Lån sparat",
"loanDeletedToast": "Lån borttaget",
"loanPaymentAddedToast": "Betalning registrerad",
- "typeLoan": "Lån"
+ "typeLoan": "Lån",
+ "tabsLabel": "Budgetsektioner",
+ "budgetTab": "Budget",
+ "loansTab": "Lån",
+ "filteredTransactions": "Filtrerade transaktioner",
+ "clearLoanFilter": "Rensa filter",
+ "loanFilterActive": "Lån: {{title}}",
+ "filterLoanTransactions": "Visa transaktioner för detta lån",
+ "loansEmptyDescription": "Skapa ett lån med +-knappen och välj Lån.",
+ "newCategoryTitle": "Ny kategori",
+ "newCategoryPlaceholder": "Kategorinamn",
+ "newSubcategoryTitle": "Ny underkategori",
+ "newSubcategoryPlaceholder": "Underkategorinamn"
},
"settings": {
"title": "Inställningar",
diff --git a/public/locales/tr.json b/public/locales/tr.json
index a4cb4e3..21d8586 100644
--- a/public/locales/tr.json
+++ b/public/locales/tr.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Borç kaydedildi",
"loanDeletedToast": "Borç silindi",
"loanPaymentAddedToast": "Ödeme kaydedildi",
- "typeLoan": "Borç"
+ "typeLoan": "Borç",
+ "tabsLabel": "Bütçe bölümleri",
+ "budgetTab": "Bütçe",
+ "loansTab": "Borçlar",
+ "filteredTransactions": "Filtrelenmiş işlemler",
+ "clearLoanFilter": "Filtreyi temizle",
+ "loanFilterActive": "Borç: {{title}}",
+ "filterLoanTransactions": "Bu borcun işlemlerini göster",
+ "loansEmptyDescription": "+ düğmesinden Borç seçerek yeni bir borç oluşturun.",
+ "newCategoryTitle": "Yeni kategori",
+ "newCategoryPlaceholder": "Kategori adı",
+ "newSubcategoryTitle": "Yeni alt kategori",
+ "newSubcategoryPlaceholder": "Alt kategori adı"
},
"settings": {
"title": "Ayarlar",
diff --git a/public/locales/uk.json b/public/locales/uk.json
index 9f2ebc2..7c5d60c 100644
--- a/public/locales/uk.json
+++ b/public/locales/uk.json
@@ -597,7 +597,19 @@
"loanSavedToast": "Позику збережено",
"loanDeletedToast": "Позику видалено",
"loanPaymentAddedToast": "Платіж записано",
- "typeLoan": "Позика"
+ "typeLoan": "Позика",
+ "tabsLabel": "Розділи бюджету",
+ "budgetTab": "Бюджет",
+ "loansTab": "Позики",
+ "filteredTransactions": "Відфільтровані операції",
+ "clearLoanFilter": "Очистити фільтр",
+ "loanFilterActive": "Позика: {{title}}",
+ "filterLoanTransactions": "Показати операції цієї позики",
+ "loansEmptyDescription": "Створіть позику кнопкою + і виберіть Позика.",
+ "newCategoryTitle": "Нова категорія",
+ "newCategoryPlaceholder": "Назва категорії",
+ "newSubcategoryTitle": "Нова підкатегорія",
+ "newSubcategoryPlaceholder": "Назва підкатегорії"
},
"settings": {
"title": "Налаштування",
diff --git a/public/locales/zh.json b/public/locales/zh.json
index 9bcade2..59204a9 100644
--- a/public/locales/zh.json
+++ b/public/locales/zh.json
@@ -597,7 +597,19 @@
"loanSavedToast": "借款已保存",
"loanDeletedToast": "借款已删除",
"loanPaymentAddedToast": "还款已记录",
- "typeLoan": "借款"
+ "typeLoan": "借款",
+ "tabsLabel": "预算分区",
+ "budgetTab": "预算",
+ "loansTab": "借款",
+ "filteredTransactions": "已筛选交易",
+ "clearLoanFilter": "清除筛选",
+ "loanFilterActive": "借款:{{title}}",
+ "filterLoanTransactions": "显示此借款的交易",
+ "loansEmptyDescription": "点击 + 按钮并选择借款来创建。",
+ "newCategoryTitle": "新类别",
+ "newCategoryPlaceholder": "类别名称",
+ "newSubcategoryTitle": "新子类别",
+ "newSubcategoryPlaceholder": "子类别名称"
},
"settings": {
"title": "设置",
diff --git a/public/pages/budget.js b/public/pages/budget.js
index dc35df9..ef7f8bf 100644
--- a/public/pages/budget.js
+++ b/public/pages/budget.js
@@ -125,6 +125,8 @@ let state = {
summary: null,
prevSummary: null, // Vormonat für Monatsvergleich
loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } },
+ activeTab: 'budget',
+ loanFilterId: null,
currency: 'EUR',
meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} },
};
@@ -161,8 +163,11 @@ function setHtml(element, html) {
async function loadMonth(month) {
const prevMonth = addMonths(month, -1);
try {
+ const entriesPath = state.loanFilterId
+ ? `/budget?loan_id=${encodeURIComponent(state.loanFilterId)}`
+ : `/budget?month=${month}`;
const [entriesRes, summaryRes, prevSummaryRes, loansRes] = await Promise.all([
- api.get(`/budget?month=${month}`),
+ api.get(entriesPath),
api.get(`/budget/summary?month=${month}`),
api.get(`/budget/summary?month=${prevMonth}`),
api.get('/budget/loans'),
@@ -224,6 +229,14 @@ export async function render(container, { user }) {
+
+
+
+
@@ -273,6 +286,12 @@ function wireNav() {
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();
}
@@ -292,10 +311,19 @@ function renderBody() {
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, `
+
@@ -324,43 +352,41 @@ function renderBody() {
` : ''}
- ${renderLoansDashboard()}
-
+
`);
if (window.lucide) lucide.createIcons();
_container.querySelector('#empty-cta-budget')?.addEventListener('click', () => {
document.querySelector('.page-fab')?.click();
});
- _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.querySelector('#budget-clear-loan-filter')?.addEventListener('click', async () => {
+ state.loanFilterId = null;
+ await loadMonth(state.month);
+ renderBody();
});
stagger(_container.querySelector('#budget-list')?.querySelectorAll('.budget-entry') ?? []);
@@ -376,6 +402,19 @@ function renderBody() {
});
}
+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 activeLoanLabel() {
+ const loan = state.loans.loans.find((item) => item.id === state.loanFilterId);
+ return loan ? t('budget.loanFilterActive', { title: loan.title }) : '';
+}
+
function renderCategoryBars(byCategory) {
const maxAbs = Math.max(...byCategory.map((c) => Math.abs(c.total)), 1);
@@ -485,6 +524,55 @@ function renderLoansDashboard() {
`;
}
+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.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', async () => {
+ state.loanFilterId = parseInt(btn.dataset.id, 10);
+ state.activeTab = 'budget';
+ await loadMonth(state.month);
+ renderBody();
+ });
+ });
+}
+
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');
@@ -493,7 +581,12 @@ function renderLoanCard(loan) {
return `
-
${esc(loan.title)}
+
+
${esc(loan.title)}
+
+
${esc(loan.borrower)} · ${t('budget.loanInstallmentMeta', {
paid: loan.paid_installments,
total: loan.installment_count,
@@ -554,7 +647,7 @@ function formatEntryDate(dateStr) {
// Modal
// --------------------------------------------------------
-function openBudgetModal({ mode, entry = null }) {
+function openBudgetModal({ mode, entry = null, initialType = '' }) {
const isEdit = mode === 'edit';
const today = new Date().toISOString().slice(0, 10);
const todayMonth = today.slice(0, 7);
@@ -671,7 +764,7 @@ function openBudgetModal({ mode, entry = null }) {
content,
size: 'sm',
onSave(panel) {
- let currentType = isExpense ? 'expense' : 'income';
+ let currentType = !isEdit && initialType === 'loan' ? 'loan' : (isExpense ? 'expense' : 'income');
const setType = (type) => {
currentType = type;
@@ -724,7 +817,11 @@ function openBudgetModal({ mode, entry = null }) {
};
const addCategory = async () => {
- const name = window.prompt(t('budget.newCategoryPrompt'));
+ 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 });
@@ -740,7 +837,11 @@ function openBudgetModal({ mode, entry = null }) {
if (currentType !== 'expense') return;
const category = panel.querySelector('#bm-category').value;
if (!category) return;
- const name = window.prompt(t('budget.newSubcategoryPrompt'));
+ 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() });
@@ -812,8 +913,7 @@ function openBudgetModal({ mode, entry = null }) {
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;
+ await loadMonth(state.month);
closeModal({ force: true });
renderBody();
@@ -829,6 +929,50 @@ function openBudgetModal({ mode, entry = null }) {
});
}
+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();
@@ -976,8 +1120,7 @@ async function deleteEntry(id) {
if (undone) return;
try {
await api.delete(`/budget/${id}`);
- const sumRes = await api.get(`/budget/summary?month=${state.month}`);
- state.summary = sumRes.data;
+ await loadMonth(state.month);
renderBody();
} catch (err) {
if (entry) {
diff --git a/public/styles/budget.css b/public/styles/budget.css
index 8b65616..bcf9582 100644
--- a/public/styles/budget.css
+++ b/public/styles/budget.css
@@ -25,6 +25,21 @@
.budget-page { height: 100dvh; }
}
+@media (max-width: 640px) {
+ .budget-nav {
+ flex-wrap: wrap;
+ }
+
+ .budget-nav__label {
+ order: 3;
+ flex-basis: 100%;
+ }
+
+ .budget-tabs {
+ margin-left: auto;
+ }
+}
+
/* --------------------------------------------------------
* Monat-Navigation
* -------------------------------------------------------- */
@@ -58,6 +73,49 @@
white-space: nowrap;
}
+.budget-tabs {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-2);
+}
+
+.budget-tab {
+ min-height: 34px;
+ padding: 0 var(--space-3);
+ border: 0;
+ border-radius: var(--radius-xs);
+ background: transparent;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.budget-tab--active {
+ background: var(--module-accent);
+ color: var(--color-text-on-accent);
+}
+
+.budget-tab-panel {
+ flex: 1;
+ min-height: 0;
+}
+
+.budget-tab-panel--budget {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.budget-tab-panel--loans {
+ overflow-y: auto;
+ padding-top: var(--space-3);
+}
+
/* --------------------------------------------------------
* Zusammenfassungs-Karten
* -------------------------------------------------------- */
@@ -252,8 +310,9 @@
grid-template-columns: 1fr;
gap: var(--space-2);
margin-top: var(--space-3);
- max-height: 260px;
+ max-height: min(58dvh, 640px);
overflow-y: auto;
+ padding-right: var(--space-1);
}
.budget-loans__empty {
@@ -283,6 +342,37 @@
white-space: nowrap;
}
+.budget-loan-card__title-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.budget-loan-card__filter {
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-2);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+}
+
+.budget-loan-card__filter:hover {
+ color: var(--module-accent);
+ border-color: var(--module-accent);
+}
+
+.budget-loan-card__filter i {
+ width: 15px;
+ height: 15px;
+}
+
.budget-loan-card__amounts {
text-align: right;
}
@@ -356,6 +446,7 @@
flex-direction: column;
overflow: hidden;
border-top: 1px solid var(--color-border);
+ min-height: 0;
}
.budget-list-header {
@@ -367,6 +458,20 @@
border-top: 3px solid var(--module-accent);
}
+.budget-list-header__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.budget-list-header__filter {
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ margin-top: 2px;
+}
+
.budget-list-header__title {
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
@@ -377,10 +482,39 @@
.budget-list {
flex: 1;
+ min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
+.budget-list,
+.budget-loans__list,
+.budget-tab-panel--loans {
+ scrollbar-width: thin;
+ scrollbar-color: var(--module-accent) transparent;
+}
+
+.budget-list::-webkit-scrollbar,
+.budget-loans__list::-webkit-scrollbar,
+.budget-tab-panel--loans::-webkit-scrollbar {
+ width: 10px;
+}
+
+.budget-list::-webkit-scrollbar-track,
+.budget-loans__list::-webkit-scrollbar-track,
+.budget-tab-panel--loans::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.budget-list::-webkit-scrollbar-thumb,
+.budget-loans__list::-webkit-scrollbar-thumb,
+.budget-tab-panel--loans::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--module-accent) 55%, transparent);
+ border: 3px solid transparent;
+ border-radius: var(--radius-full);
+ background-clip: padding-box;
+}
+
.budget-entry {
display: flex;
align-items: center;
@@ -528,3 +662,39 @@
padding: 2px var(--space-2);
font-size: var(--text-xs);
}
+
+.budget-inline-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-4);
+ background: rgba(0, 0, 0, 0.38);
+}
+
+.budget-inline-modal__panel {
+ width: min(100%, 380px);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-surface);
+ box-shadow: var(--shadow-lg);
+}
+
+.budget-inline-modal__header,
+.budget-inline-modal__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.budget-inline-modal__header {
+ margin-bottom: var(--space-3);
+}
+
+.budget-inline-modal__footer {
+ justify-content: flex-end;
+}
diff --git a/server/routes/budget.js b/server/routes/budget.js
index 00e123c..f914e09 100644
--- a/server/routes/budget.js
+++ b/server/routes/budget.js
@@ -287,6 +287,22 @@ function refreshLoanStatus(loanId) {
return loan;
}
+function entryWithLoanMeta(id) {
+ return db.get().prepare(`
+ SELECT b.*, u.display_name AS creator_name,
+ p.id AS loan_payment_id,
+ p.loan_id AS loan_id,
+ p.installment_number AS loan_installment_number,
+ l.title AS loan_title,
+ l.borrower AS loan_borrower
+ FROM budget_entries b
+ LEFT JOIN users u ON u.id = b.created_by
+ LEFT JOIN budget_loan_payments p ON p.budget_entry_id = b.id
+ LEFT JOIN budget_loans l ON l.id = p.loan_id
+ WHERE b.id = ?
+ `).get(id);
+}
+
// --------------------------------------------------------
// Statische Routen vor /:id
// --------------------------------------------------------
@@ -761,21 +777,36 @@ router.get('/', (req, res) => {
try {
const today = new Date().toISOString().slice(0, 7);
const month = req.query.month || today;
+ const loanId = req.query.loan_id ? parseInt(req.query.loan_id, 10) : null;
- if (!MONTH_RE.test(month))
+ if (!loanId && !MONTH_RE.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
- generateRecurringInstances(db.get(), month);
+ if (!loanId) generateRecurringInstances(db.get(), month);
const from = `${month}-01`;
const to = `${month}-31`;
let sql = `
- SELECT b.*, u.display_name AS creator_name
+ SELECT b.*, u.display_name AS creator_name,
+ p.id AS loan_payment_id,
+ p.loan_id AS loan_id,
+ p.installment_number AS loan_installment_number,
+ l.title AS loan_title,
+ l.borrower AS loan_borrower
FROM budget_entries b
LEFT JOIN users u ON u.id = b.created_by
- WHERE b.date BETWEEN ? AND ?
+ LEFT JOIN budget_loan_payments p ON p.budget_entry_id = b.id
+ LEFT JOIN budget_loans l ON l.id = p.loan_id
`;
- const params = [from, to];
+ const params = [];
+
+ if (loanId) {
+ sql += ' WHERE p.loan_id = ?';
+ params.push(loanId);
+ } else {
+ sql += ' WHERE b.date BETWEEN ? AND ?';
+ params.push(from, to);
+ }
if (req.query.category && validCategoryKeys().includes(req.query.category)) {
sql += ' AND b.category = ?';
@@ -822,11 +853,7 @@ router.post('/', (req, res) => {
req.session.userId
);
- const entry = db.get().prepare(`
- SELECT b.*, u.display_name AS creator_name
- FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by
- WHERE b.id = ?
- `).get(result.lastInsertRowid);
+ const entry = entryWithLoanMeta(result.lastInsertRowid);
res.status(201).json({ data: entry });
} catch (err) {
@@ -856,6 +883,12 @@ router.put('/:id', (req, res) => {
const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const { title, amount, category, subcategory: requestedSubcategory, date, is_recurring, recurrence_rule } = req.body;
+ const linkedPayment = db.get().prepare(`
+ SELECT * FROM budget_loan_payments WHERE budget_entry_id = ?
+ `).get(id);
+ if (linkedPayment && amount !== undefined && Number(amount) <= 0) {
+ return res.status(400).json({ error: 'Loan repayment entries must remain income.', code: 400 });
+ }
const nextCategory = category ?? entry.category;
const subcategory = requestedSubcategory !== undefined || category !== undefined
? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory)
@@ -864,31 +897,45 @@ router.put('/:id', (req, res) => {
return res.status(400).json({ error: 'Invalid subcategory.', code: 400 });
}
- db.get().prepare(`
- UPDATE budget_entries
- SET title = COALESCE(?, title),
- amount = COALESCE(?, amount),
- category = COALESCE(?, category),
- subcategory = COALESCE(?, subcategory),
- date = COALESCE(?, date),
- is_recurring = COALESCE(?, is_recurring),
- recurrence_rule = ?
- WHERE id = ?
- `).run(
- title?.trim() ?? null,
- amount !== undefined ? Number(amount) : null,
- category ?? null,
- subcategory !== undefined ? subcategory : null,
- date ?? null,
- is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
- recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
- id
- );
+ const tx = db.get().transaction(() => {
+ db.get().prepare(`
+ UPDATE budget_entries
+ SET title = COALESCE(?, title),
+ amount = COALESCE(?, amount),
+ category = COALESCE(?, category),
+ subcategory = COALESCE(?, subcategory),
+ date = COALESCE(?, date),
+ is_recurring = COALESCE(?, is_recurring),
+ recurrence_rule = ?
+ WHERE id = ?
+ `).run(
+ title?.trim() ?? null,
+ amount !== undefined ? Number(amount) : null,
+ category ?? null,
+ subcategory !== undefined ? subcategory : null,
+ date ?? null,
+ is_recurring !== undefined ? (is_recurring ? 1 : 0) : null,
+ recurrence_rule !== undefined ? (recurrence_rule || null) : entry.recurrence_rule,
+ id
+ );
- const updated = db.get().prepare(`
- SELECT b.*, u.display_name AS creator_name
- FROM budget_entries b LEFT JOIN users u ON u.id = b.created_by WHERE b.id = ?
- `).get(id);
+ if (linkedPayment) {
+ db.get().prepare(`
+ UPDATE budget_loan_payments
+ SET amount = COALESCE(?, amount),
+ paid_date = COALESCE(?, paid_date)
+ WHERE id = ?
+ `).run(
+ amount !== undefined ? cents(amount) : null,
+ date ?? null,
+ linkedPayment.id
+ );
+ refreshLoanStatus(linkedPayment.loan_id);
+ }
+ });
+ tx();
+
+ const updated = entryWithLoanMeta(id);
res.json({ data: updated });
} catch (err) {
@@ -908,7 +955,18 @@ router.delete('/:id', (req, res) => {
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Entry not found', code: 404 });
- db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
+ const linkedPayment = db.get().prepare(`
+ SELECT * FROM budget_loan_payments WHERE budget_entry_id = ?
+ `).get(id);
+
+ const tx = db.get().transaction(() => {
+ if (linkedPayment) {
+ db.get().prepare('DELETE FROM budget_loan_payments WHERE id = ?').run(linkedPayment.id);
+ }
+ db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
+ if (linkedPayment) refreshLoanStatus(linkedPayment.loan_id);
+ });
+ tx();
// Wenn eine Instanz gelöscht wird: Monat als übersprungen markieren
if (entry.recurrence_parent_id) {