Separate budget and loan views

This commit is contained in:
Rafael Foster
2026-05-01 07:52:43 -03:00
parent 9a80b785c8
commit 977bee8a3a
18 changed files with 631 additions and 80 deletions
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "تم حفظ القرض",
"loanDeletedToast": "تم حذف القرض",
"loanPaymentAddedToast": "تم تسجيل الدفع",
"typeLoan": "قرض"
"typeLoan": "قرض",
"tabsLabel": "أقسام الميزانية",
"budgetTab": "الميزانية",
"loansTab": "القروض",
"filteredTransactions": "المعاملات المصفاة",
"clearLoanFilter": "مسح الفلتر",
"loanFilterActive": "القرض: {{title}}",
"filterLoanTransactions": "عرض معاملات هذا القرض",
"loansEmptyDescription": "أنشئ قرضًا من زر + واختر قرض.",
"newCategoryTitle": "فئة جديدة",
"newCategoryPlaceholder": "اسم الفئة",
"newSubcategoryTitle": "فئة فرعية جديدة",
"newSubcategoryPlaceholder": "اسم الفئة الفرعية"
},
"settings": {
"title": "الإعدادات",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "Το δάνειο αποθηκεύτηκε",
"loanDeletedToast": "Το δάνειο διαγράφηκε",
"loanPaymentAddedToast": "Η πληρωμή καταγράφηκε",
"typeLoan": "Δάνειο"
"typeLoan": "Δάνειο",
"tabsLabel": "Ενότητες προϋπολογισμού",
"budgetTab": "Προϋπολογισμός",
"loansTab": "Δάνεια",
"filteredTransactions": "Φιλτραρισμένες συναλλαγές",
"clearLoanFilter": "Καθαρισμός φίλτρου",
"loanFilterActive": "Δάνειο: {{title}}",
"filterLoanTransactions": "Εμφάνιση συναλλαγών αυτού του δανείου",
"loansEmptyDescription": "Δημιουργήστε δάνειο από το κουμπί + και επιλέξτε Δάνειο.",
"newCategoryTitle": "Νέα κατηγορία",
"newCategoryPlaceholder": "Όνομα κατηγορίας",
"newSubcategoryTitle": "Νέα υποκατηγορία",
"newSubcategoryPlaceholder": "Όνομα υποκατηγορίας"
},
"settings": {
"title": "Ρυθμίσεις",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "उधार सहेजा गया",
"loanDeletedToast": "उधार हटाया गया",
"loanPaymentAddedToast": "भुगतान दर्ज किया गया",
"typeLoan": "उधार"
"typeLoan": "उधार",
"tabsLabel": "बजट अनुभाग",
"budgetTab": "बजट",
"loansTab": "उधार",
"filteredTransactions": "फ़िल्टर किए गए लेन-देन",
"clearLoanFilter": "फ़िल्टर हटाएं",
"loanFilterActive": "उधार: {{title}}",
"filterLoanTransactions": "इस उधार के लेन-देन दिखाएं",
"loansEmptyDescription": "+ बटन से उधार चुनकर नया उधार बनाएं।",
"newCategoryTitle": "नई श्रेणी",
"newCategoryPlaceholder": "श्रेणी का नाम",
"newSubcategoryTitle": "नई उपश्रेणी",
"newSubcategoryPlaceholder": "उपश्रेणी का नाम"
},
"settings": {
"title": "सेटिंग्स",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "貸付を保存しました",
"loanDeletedToast": "貸付を削除しました",
"loanPaymentAddedToast": "返済を記録しました",
"typeLoan": "貸付"
"typeLoan": "貸付",
"tabsLabel": "予算セクション",
"budgetTab": "予算",
"loansTab": "貸付",
"filteredTransactions": "絞り込み済み取引",
"clearLoanFilter": "フィルター解除",
"loanFilterActive": "貸付:{{title}}",
"filterLoanTransactions": "この貸付の取引を表示",
"loansEmptyDescription": "+ ボタンから貸付を選んで作成します。",
"newCategoryTitle": "新しいカテゴリ",
"newCategoryPlaceholder": "カテゴリ名",
"newSubcategoryTitle": "新しいサブカテゴリ",
"newSubcategoryPlaceholder": "サブカテゴリ名"
},
"settings": {
"title": "設定",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "Займ сохранён",
"loanDeletedToast": "Займ удалён",
"loanPaymentAddedToast": "Платёж записан",
"typeLoan": "Займ"
"typeLoan": "Займ",
"tabsLabel": "Разделы бюджета",
"budgetTab": "Бюджет",
"loansTab": "Займы",
"filteredTransactions": "Отфильтрованные операции",
"clearLoanFilter": "Сбросить фильтр",
"loanFilterActive": "Займ: {{title}}",
"filterLoanTransactions": "Показать операции этого займа",
"loansEmptyDescription": "Создайте займ кнопкой + и выберите Займ.",
"newCategoryTitle": "Новая категория",
"newCategoryPlaceholder": "Название категории",
"newSubcategoryTitle": "Новая подкатегория",
"newSubcategoryPlaceholder": "Название подкатегории"
},
"settings": {
"title": "Настройки",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "Позику збережено",
"loanDeletedToast": "Позику видалено",
"loanPaymentAddedToast": "Платіж записано",
"typeLoan": "Позика"
"typeLoan": "Позика",
"tabsLabel": "Розділи бюджету",
"budgetTab": "Бюджет",
"loansTab": "Позики",
"filteredTransactions": "Відфільтровані операції",
"clearLoanFilter": "Очистити фільтр",
"loanFilterActive": "Позика: {{title}}",
"filterLoanTransactions": "Показати операції цієї позики",
"loansEmptyDescription": "Створіть позику кнопкою + і виберіть Позика.",
"newCategoryTitle": "Нова категорія",
"newCategoryPlaceholder": "Назва категорії",
"newSubcategoryTitle": "Нова підкатегорія",
"newSubcategoryPlaceholder": "Назва підкатегорії"
},
"settings": {
"title": "Налаштування",
+13 -1
View File
@@ -597,7 +597,19 @@
"loanSavedToast": "借款已保存",
"loanDeletedToast": "借款已删除",
"loanPaymentAddedToast": "还款已记录",
"typeLoan": "借款"
"typeLoan": "借款",
"tabsLabel": "预算分区",
"budgetTab": "预算",
"loansTab": "借款",
"filteredTransactions": "已筛选交易",
"clearLoanFilter": "清除筛选",
"loanFilterActive": "借款:{{title}}",
"filterLoanTransactions": "显示此借款的交易",
"loansEmptyDescription": "点击 + 按钮并选择借款来创建。",
"newCategoryTitle": "新类别",
"newCategoryPlaceholder": "类别名称",
"newSubcategoryTitle": "新子类别",
"newSubcategoryPlaceholder": "子类别名称"
},
"settings": {
"title": "设置",
+171 -28
View File
@@ -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 }) {
</button>
<button class="budget-nav__today" id="budget-today">${t('budget.currentMonth')}</button>
<span class="budget-nav__label" id="budget-label"></span>
<div class="budget-tabs" role="tablist" aria-label="${t('budget.tabsLabel')}">
<button class="budget-tab" id="budget-tab-budget" type="button" role="tab" aria-selected="true" data-tab="budget">
${t('budget.budgetTab')}
</button>
<button class="budget-tab" id="budget-tab-loans" type="button" role="tab" aria-selected="false" data-tab="loans">
${t('budget.loansTab')}
</button>
</div>
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="${t('budget.addEntryLabel')}">
<i data-lucide="plus" aria-hidden="true"></i>
</button>
@@ -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, `
<div class="budget-tab-panel budget-tab-panel--budget">
<!-- Zusammenfassung -->
<div class="budget-summary">
<div class="budget-summary-card budget-summary-card--income">
@@ -324,43 +352,41 @@ function renderBody() {
</div>
</div>` : ''}
${renderLoansDashboard()}
<!-- Transaktionsliste -->
<div class="budget-list-section">
<div class="budget-list-header">
<span class="budget-list-header__title">${t('budget.transactions')}</span>
${state.entries.length ? `
<div>
<span class="budget-list-header__title">${state.loanFilterId ? t('budget.filteredTransactions') : t('budget.transactions')}</span>
${state.loanFilterId ? `<div class="budget-list-header__filter">${esc(activeLoanLabel())}</div>` : ''}
</div>
<div class="budget-list-header__actions">
${state.loanFilterId ? `
<button class="btn btn--secondary" id="budget-clear-loan-filter"
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
<i data-lucide="x" style="width:14px;height:14px;margin-right:4px;" aria-hidden="true"></i>${t('budget.clearLoanFilter')}
</button>` : ''}
${state.entries.length && !state.loanFilterId ? `
<a href="/api/v1/budget/export?month=${state.month}" class="btn btn--secondary"
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
<i data-lucide="download" style="width:14px;height:14px;margin-right:4px;" aria-hidden="true"></i>CSV
</a>` : ''}
</div>
</div>
<div class="budget-list" id="budget-list">
${renderEntries()}
</div>
</div>
</div>
`);
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 `<div class="budget-tab-panel budget-tab-panel--loans">
<div class="empty-state">
<i data-lucide="hand-coins" class="empty-state__icon" aria-hidden="true"></i>
<div class="empty-state__title">${t('budget.loansEmpty')}</div>
<div class="empty-state__description">${t('budget.loansEmptyDescription')}</div>
<button class="btn btn--primary empty-state__cta" id="budget-empty-loan">
<i data-lucide="plus" aria-hidden="true" class="icon-base"></i>
${t('budget.newLoan')}
</button>
</div>
</div>`;
}
return `<div class="budget-tab-panel budget-tab-panel--loans">
${renderLoansDashboard()}
</div>`;
}
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 `
<article class="budget-loan-card">
<div class="budget-loan-card__main">
<div class="budget-loan-card__title-row">
<div class="budget-loan-card__title">${esc(loan.title)}</div>
<button class="budget-loan-card__filter" data-action="loan-filter" data-id="${loan.id}" aria-label="${t('budget.filterLoanTransactions')}">
<i data-lucide="filter" aria-hidden="true"></i>
</button>
</div>
<div class="budget-loan-card__meta">${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, `
<div class="budget-inline-modal__panel" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<div class="budget-inline-modal__header">
<strong>${esc(title)}</strong>
<button class="btn btn--icon" type="button" data-action="inline-cancel" aria-label="${t('common.cancel')}">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<div class="form-group">
<label class="form-label" for="budget-inline-name">${esc(label)}</label>
<input class="form-input" id="budget-inline-name" type="text" placeholder="${esc(placeholder)}">
</div>
<div class="budget-inline-modal__footer">
<button class="btn btn--secondary" type="button" data-action="inline-cancel">${t('common.cancel')}</button>
<button class="btn btn--primary" type="button" data-action="inline-save">${t('common.add')}</button>
</div>
</div>
`);
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) {
+171 -1
View File
@@ -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;
}
+72 -14
View File
@@ -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,6 +897,7 @@ router.put('/:id', (req, res) => {
return res.status(400).json({ error: 'Invalid subcategory.', code: 400 });
}
const tx = db.get().transaction(() => {
db.get().prepare(`
UPDATE budget_entries
SET title = COALESCE(?, title),
@@ -885,10 +919,23 @@ router.put('/:id', (req, res) => {
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 });
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) {