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()} -
- ${t('budget.transactions')} - ${state.entries.length ? ` +
+ ${state.loanFilterId ? t('budget.filteredTransactions') : t('budget.transactions')} + ${state.loanFilterId ? `
${esc(activeLoanLabel())}
` : ''} +
+
+ ${state.loanFilterId ? ` + ` : ''} + ${state.entries.length && !state.loanFilterId ? ` CSV ` : ''} +
${renderEntries()}
+
`); 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) {