Improve loan dashboard interactions

This commit is contained in:
Rafael Foster
2026-05-01 08:09:12 -03:00
parent 977bee8a3a
commit 79f55cbfbc
18 changed files with 425 additions and 24 deletions
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "فئة جديدة", "newCategoryTitle": "فئة جديدة",
"newCategoryPlaceholder": "اسم الفئة", "newCategoryPlaceholder": "اسم الفئة",
"newSubcategoryTitle": "فئة فرعية جديدة", "newSubcategoryTitle": "فئة فرعية جديدة",
"newSubcategoryPlaceholder": "اسم الفئة الفرعية" "newSubcategoryPlaceholder": "اسم الفئة الفرعية",
"loanStatusFilterLabel": "فلتر حالة القرض",
"loanStatusActive": "نشطة",
"loanStatusPaid": "مدفوعة",
"loanStatusAll": "الكل",
"loanTransactions": "معاملات القرض",
"loanInstallmentNumber": "القسط {{number}} من {{total}}",
"loanReportTitle": "تقرير القرض",
"loanNoTransactions": "لم يتم تسجيل أي دفعات بعد."
}, },
"settings": { "settings": {
"title": "الإعدادات", "title": "الإعدادات",
+9 -1
View File
@@ -634,7 +634,15 @@
"newCategoryTitle": "Neue Kategorie", "newCategoryTitle": "Neue Kategorie",
"newCategoryPlaceholder": "Kategoriename", "newCategoryPlaceholder": "Kategoriename",
"newSubcategoryTitle": "Neue Unterkategorie", "newSubcategoryTitle": "Neue Unterkategorie",
"newSubcategoryPlaceholder": "Name der Unterkategorie" "newSubcategoryPlaceholder": "Name der Unterkategorie",
"loanStatusFilterLabel": "Darlehensstatus filtern",
"loanStatusActive": "Aktiv",
"loanStatusPaid": "Bezahlt",
"loanStatusAll": "Alle",
"loanTransactions": "Darlehenstransaktionen",
"loanInstallmentNumber": "Rate {{number}} von {{total}}",
"loanReportTitle": "Darlehensbericht",
"loanNoTransactions": "Noch keine Zahlungen erfasst."
}, },
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Νέα κατηγορία", "newCategoryTitle": "Νέα κατηγορία",
"newCategoryPlaceholder": "Όνομα κατηγορίας", "newCategoryPlaceholder": "Όνομα κατηγορίας",
"newSubcategoryTitle": "Νέα υποκατηγορία", "newSubcategoryTitle": "Νέα υποκατηγορία",
"newSubcategoryPlaceholder": "Όνομα υποκατηγορίας" "newSubcategoryPlaceholder": "Όνομα υποκατηγορίας",
"loanStatusFilterLabel": "Φίλτρο κατάστασης δανείων",
"loanStatusActive": "Ενεργά",
"loanStatusPaid": "Πληρωμένα",
"loanStatusAll": "Όλα",
"loanTransactions": "Συναλλαγές δανείου",
"loanInstallmentNumber": "Δόση {{number}} από {{total}}",
"loanReportTitle": "Αναφορά δανείου",
"loanNoTransactions": "Δεν έχουν καταγραφεί πληρωμές ακόμα."
}, },
"settings": { "settings": {
"title": "Ρυθμίσεις", "title": "Ρυθμίσεις",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "New category", "newCategoryTitle": "New category",
"newCategoryPlaceholder": "Category name", "newCategoryPlaceholder": "Category name",
"newSubcategoryTitle": "New subcategory", "newSubcategoryTitle": "New subcategory",
"newSubcategoryPlaceholder": "Subcategory name" "newSubcategoryPlaceholder": "Subcategory name",
"loanStatusFilterLabel": "Loan status filter",
"loanStatusActive": "Active",
"loanStatusPaid": "Paid",
"loanStatusAll": "All",
"loanTransactions": "Loan transactions",
"loanInstallmentNumber": "Installment {{number}} of {{total}}",
"loanReportTitle": "Loan report",
"loanNoTransactions": "No payments recorded yet."
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Nueva categoría", "newCategoryTitle": "Nueva categoría",
"newCategoryPlaceholder": "Nombre de la categoría", "newCategoryPlaceholder": "Nombre de la categoría",
"newSubcategoryTitle": "Nueva subcategoría", "newSubcategoryTitle": "Nueva subcategoría",
"newSubcategoryPlaceholder": "Nombre de la subcategoría" "newSubcategoryPlaceholder": "Nombre de la subcategoría",
"loanStatusFilterLabel": "Filtro de estado de préstamos",
"loanStatusActive": "Activos",
"loanStatusPaid": "Pagados",
"loanStatusAll": "Todos",
"loanTransactions": "Transacciones del préstamo",
"loanInstallmentNumber": "Cuota {{number}} de {{total}}",
"loanReportTitle": "Informe del préstamo",
"loanNoTransactions": "Aún no hay pagos registrados."
}, },
"settings": { "settings": {
"title": "Ajustes", "title": "Ajustes",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Nouvelle catégorie", "newCategoryTitle": "Nouvelle catégorie",
"newCategoryPlaceholder": "Nom de la catégorie", "newCategoryPlaceholder": "Nom de la catégorie",
"newSubcategoryTitle": "Nouvelle sous-catégorie", "newSubcategoryTitle": "Nouvelle sous-catégorie",
"newSubcategoryPlaceholder": "Nom de la sous-catégorie" "newSubcategoryPlaceholder": "Nom de la sous-catégorie",
"loanStatusFilterLabel": "Filtre de statut des prêts",
"loanStatusActive": "Actifs",
"loanStatusPaid": "Payés",
"loanStatusAll": "Tous",
"loanTransactions": "Transactions du prêt",
"loanInstallmentNumber": "Échéance {{number}} sur {{total}}",
"loanReportTitle": "Rapport du prêt",
"loanNoTransactions": "Aucun paiement enregistré."
}, },
"settings": { "settings": {
"title": "Paramètres", "title": "Paramètres",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "नई श्रेणी", "newCategoryTitle": "नई श्रेणी",
"newCategoryPlaceholder": "श्रेणी का नाम", "newCategoryPlaceholder": "श्रेणी का नाम",
"newSubcategoryTitle": "नई उपश्रेणी", "newSubcategoryTitle": "नई उपश्रेणी",
"newSubcategoryPlaceholder": "उपश्रेणी का नाम" "newSubcategoryPlaceholder": "उपश्रेणी का नाम",
"loanStatusFilterLabel": "उधार स्थिति फ़िल्टर",
"loanStatusActive": "सक्रिय",
"loanStatusPaid": "चुकाया गया",
"loanStatusAll": "सभी",
"loanTransactions": "उधार लेन-देन",
"loanInstallmentNumber": "{{total}} में से किस्त {{number}}",
"loanReportTitle": "उधार रिपोर्ट",
"loanNoTransactions": "अभी कोई भुगतान दर्ज नहीं है।"
}, },
"settings": { "settings": {
"title": "सेटिंग्स", "title": "सेटिंग्स",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Nuova categoria", "newCategoryTitle": "Nuova categoria",
"newCategoryPlaceholder": "Nome categoria", "newCategoryPlaceholder": "Nome categoria",
"newSubcategoryTitle": "Nuova sottocategoria", "newSubcategoryTitle": "Nuova sottocategoria",
"newSubcategoryPlaceholder": "Nome sottocategoria" "newSubcategoryPlaceholder": "Nome sottocategoria",
"loanStatusFilterLabel": "Filtro stato prestiti",
"loanStatusActive": "Attivi",
"loanStatusPaid": "Pagati",
"loanStatusAll": "Tutti",
"loanTransactions": "Movimenti del prestito",
"loanInstallmentNumber": "Rata {{number}} di {{total}}",
"loanReportTitle": "Report del prestito",
"loanNoTransactions": "Nessun pagamento registrato."
}, },
"settings": { "settings": {
"title": "Impostazioni", "title": "Impostazioni",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "新しいカテゴリ", "newCategoryTitle": "新しいカテゴリ",
"newCategoryPlaceholder": "カテゴリ名", "newCategoryPlaceholder": "カテゴリ名",
"newSubcategoryTitle": "新しいサブカテゴリ", "newSubcategoryTitle": "新しいサブカテゴリ",
"newSubcategoryPlaceholder": "サブカテゴリ名" "newSubcategoryPlaceholder": "サブカテゴリ名",
"loanStatusFilterLabel": "貸付ステータスフィルター",
"loanStatusActive": "進行中",
"loanStatusPaid": "完済",
"loanStatusAll": "すべて",
"loanTransactions": "貸付取引",
"loanInstallmentNumber": "{{total}} 回中 {{number}} 回目",
"loanReportTitle": "貸付レポート",
"loanNoTransactions": "返済はまだ記録されていません。"
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Nova categoria", "newCategoryTitle": "Nova categoria",
"newCategoryPlaceholder": "Nome da categoria", "newCategoryPlaceholder": "Nome da categoria",
"newSubcategoryTitle": "Nova subcategoria", "newSubcategoryTitle": "Nova subcategoria",
"newSubcategoryPlaceholder": "Nome da subcategoria" "newSubcategoryPlaceholder": "Nome da subcategoria",
"loanStatusFilterLabel": "Filtro de status dos empréstimos",
"loanStatusActive": "Ativos",
"loanStatusPaid": "Pagos",
"loanStatusAll": "Todos",
"loanTransactions": "Transações do empréstimo",
"loanInstallmentNumber": "Parcela {{number}} de {{total}}",
"loanReportTitle": "Relatório do empréstimo",
"loanNoTransactions": "Nenhum pagamento registrado ainda."
}, },
"settings": { "settings": {
"title": "Configurações", "title": "Configurações",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Новая категория", "newCategoryTitle": "Новая категория",
"newCategoryPlaceholder": "Название категории", "newCategoryPlaceholder": "Название категории",
"newSubcategoryTitle": "Новая подкатегория", "newSubcategoryTitle": "Новая подкатегория",
"newSubcategoryPlaceholder": "Название подкатегории" "newSubcategoryPlaceholder": "Название подкатегории",
"loanStatusFilterLabel": "Фильтр статуса займов",
"loanStatusActive": "Активные",
"loanStatusPaid": "Оплаченные",
"loanStatusAll": "Все",
"loanTransactions": "Операции займа",
"loanInstallmentNumber": "Платёж {{number}} из {{total}}",
"loanReportTitle": "Отчёт по займу",
"loanNoTransactions": "Платежи ещё не записаны."
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Ny kategori", "newCategoryTitle": "Ny kategori",
"newCategoryPlaceholder": "Kategorinamn", "newCategoryPlaceholder": "Kategorinamn",
"newSubcategoryTitle": "Ny underkategori", "newSubcategoryTitle": "Ny underkategori",
"newSubcategoryPlaceholder": "Underkategorinamn" "newSubcategoryPlaceholder": "Underkategorinamn",
"loanStatusFilterLabel": "Filter för lånestatus",
"loanStatusActive": "Aktiva",
"loanStatusPaid": "Betalda",
"loanStatusAll": "Alla",
"loanTransactions": "Lånetransaktioner",
"loanInstallmentNumber": "Delbetalning {{number}} av {{total}}",
"loanReportTitle": "Lånrapport",
"loanNoTransactions": "Inga betalningar registrerade ännu."
}, },
"settings": { "settings": {
"title": "Inställningar", "title": "Inställningar",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Yeni kategori", "newCategoryTitle": "Yeni kategori",
"newCategoryPlaceholder": "Kategori adı", "newCategoryPlaceholder": "Kategori adı",
"newSubcategoryTitle": "Yeni alt kategori", "newSubcategoryTitle": "Yeni alt kategori",
"newSubcategoryPlaceholder": "Alt kategori adı" "newSubcategoryPlaceholder": "Alt kategori adı",
"loanStatusFilterLabel": "Borç durumu filtresi",
"loanStatusActive": "Aktif",
"loanStatusPaid": "Ödendi",
"loanStatusAll": "Tümü",
"loanTransactions": "Borç işlemleri",
"loanInstallmentNumber": "{{total}} taksitten {{number}}.",
"loanReportTitle": "Borç raporu",
"loanNoTransactions": "Henüz ödeme kaydedilmedi."
}, },
"settings": { "settings": {
"title": "Ayarlar", "title": "Ayarlar",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "Нова категорія", "newCategoryTitle": "Нова категорія",
"newCategoryPlaceholder": "Назва категорії", "newCategoryPlaceholder": "Назва категорії",
"newSubcategoryTitle": "Нова підкатегорія", "newSubcategoryTitle": "Нова підкатегорія",
"newSubcategoryPlaceholder": "Назва підкатегорії" "newSubcategoryPlaceholder": "Назва підкатегорії",
"loanStatusFilterLabel": "Фільтр статусу позик",
"loanStatusActive": "Активні",
"loanStatusPaid": "Сплачені",
"loanStatusAll": "Усі",
"loanTransactions": "Операції позики",
"loanInstallmentNumber": "Платіж {{number}} з {{total}}",
"loanReportTitle": "Звіт по позиці",
"loanNoTransactions": "Платежі ще не записано."
}, },
"settings": { "settings": {
"title": "Налаштування", "title": "Налаштування",
+9 -1
View File
@@ -609,7 +609,15 @@
"newCategoryTitle": "新类别", "newCategoryTitle": "新类别",
"newCategoryPlaceholder": "类别名称", "newCategoryPlaceholder": "类别名称",
"newSubcategoryTitle": "新子类别", "newSubcategoryTitle": "新子类别",
"newSubcategoryPlaceholder": "子类别名称" "newSubcategoryPlaceholder": "子类别名称",
"loanStatusFilterLabel": "借款状态筛选",
"loanStatusActive": "进行中",
"loanStatusPaid": "已还清",
"loanStatusAll": "全部",
"loanTransactions": "借款交易",
"loanInstallmentNumber": "第 {{number}} / {{total}} 期",
"loanReportTitle": "借款报告",
"loanNoTransactions": "尚未记录还款。"
}, },
"settings": { "settings": {
"title": "设置", "title": "设置",
+116 -4
View File
@@ -127,6 +127,7 @@ let state = {
loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }, loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } },
activeTab: 'budget', activeTab: 'budget',
loanFilterId: null, loanFilterId: null,
loanStatusFilter: 'active',
currency: 'EUR', currency: 'EUR',
meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }, meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} },
}; };
@@ -486,7 +487,7 @@ function renderLoansDashboard() {
if (!loans.length) return ''; if (!loans.length) return '';
const summary = state.loans?.summary ?? {}; const summary = state.loans?.summary ?? {};
const activeLoans = loans.filter((loan) => loan.status === 'active'); const visibleLoans = filteredLoans();
return ` return `
<section class="budget-loans"> <section class="budget-loans">
@@ -498,6 +499,14 @@ function renderLoansDashboard() {
amount: formatAmount(summary.remaining_amount ?? 0), amount: formatAmount(summary.remaining_amount ?? 0),
})}</div> })}</div>
</div> </div>
<div class="budget-loans__filters" role="group" aria-label="${t('budget.loanStatusFilterLabel')}">
<button class="budget-loans__filter ${state.loanStatusFilter === 'active' ? 'budget-loans__filter--active' : ''}"
type="button" data-loan-status="active">${t('budget.loanStatusActive')}</button>
<button class="budget-loans__filter ${state.loanStatusFilter === 'paid' ? 'budget-loans__filter--active' : ''}"
type="button" data-loan-status="paid">${t('budget.loanStatusPaid')}</button>
<button class="budget-loans__filter ${state.loanStatusFilter === 'all' ? 'budget-loans__filter--active' : ''}"
type="button" data-loan-status="all">${t('budget.loanStatusAll')}</button>
</div>
</div> </div>
<div class="budget-loans__stats"> <div class="budget-loans__stats">
<div> <div>
@@ -513,17 +522,55 @@ function renderLoansDashboard() {
<strong>${formatAmount(summary.paid_amount ?? 0)}</strong> <strong>${formatAmount(summary.paid_amount ?? 0)}</strong>
</div> </div>
</div> </div>
${activeLoans.length ? ` ${visibleLoans.length ? `
<div class="budget-loans__list"> <div class="budget-loans__list">
${activeLoans.map(renderLoanCard).join('')} ${visibleLoans.map(renderLoanCard).join('')}
</div> </div>
` : ` ` : `
<div class="budget-loans__empty">${t('budget.loansEmpty')}</div> <div class="budget-loans__empty">${t('budget.loansEmpty')}</div>
`} `}
${renderLoanTransactions(visibleLoans)}
</section> </section>
`; `;
} }
function filteredLoans() {
const loans = state.loans?.loans ?? [];
if (state.loanStatusFilter === 'all') return loans;
return loans.filter((loan) => loan.status === state.loanStatusFilter);
}
function loanPaymentsFor(loans) {
return loans.flatMap((loan) => (loan.payments ?? []).map((payment) => ({ ...payment, loan })))
.sort((a, b) => new Date(b.paid_date) - new Date(a.paid_date) || b.installment_number - a.installment_number);
}
function renderLoanTransactions(loans) {
const payments = loanPaymentsFor(loans);
if (!payments.length) return '';
return `<div class="budget-loan-transactions">
<div class="budget-loan-transactions__title">${t('budget.loanTransactions')}</div>
<div class="budget-loan-transactions__list">
${payments.map(({ loan, ...payment }) => `
<div class="budget-loan-transaction">
<div>
<strong>${esc(loan.title)}</strong>
<span>${esc(loan.borrower)} · ${t('budget.loanInstallmentNumber', {
number: payment.installment_number,
total: loan.installment_count,
})}</span>
</div>
<div>
<strong>${formatAmount(payment.amount)}</strong>
<span>${formatEntryDate(payment.paid_date)}</span>
</div>
</div>
`).join('')}
</div>
</div>`;
}
function renderLoansPage() { function renderLoansPage() {
const loans = state.loans?.loans ?? []; const loans = state.loans?.loans ?? [];
if (!loans.length) { if (!loans.length) {
@@ -547,6 +594,19 @@ function renderLoansPage() {
function wireLoansPage() { function wireLoansPage() {
_container.querySelector('#budget-empty-loan')?.addEventListener('click', () => openBudgetModal({ mode: 'create', initialType: 'loan' })); _container.querySelector('#budget-empty-loan')?.addEventListener('click', () => openBudgetModal({ mode: 'create', initialType: 'loan' }));
_container.querySelectorAll('[data-loan-status]').forEach((btn) => {
btn.addEventListener('click', () => {
state.loanStatusFilter = btn.dataset.loanStatus;
renderBody();
});
});
_container.querySelectorAll('.budget-loan-card[data-loan-id]').forEach((card) => {
card.addEventListener('click', (event) => {
if (event.target.closest('button, a')) return;
const loan = state.loans.loans.find((item) => item.id === parseInt(card.dataset.loanId, 10));
if (loan) openLoanReport(loan);
});
});
_container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => { _container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
await markLoanPayment(parseInt(btn.dataset.id, 10)); await markLoanPayment(parseInt(btn.dataset.id, 10));
@@ -573,13 +633,65 @@ function wireLoansPage() {
}); });
} }
function openLoanReport(loan) {
const payments = (loan.payments ?? []).slice()
.sort((a, b) => new Date(b.paid_date) - new Date(a.paid_date) || b.installment_number - a.installment_number);
const content = `
<div class="loan-report">
<div class="loan-report__hero">
<div>
<div class="loan-report__borrower">${esc(loan.borrower)}</div>
<div class="loan-report__title">${esc(loan.title)}</div>
</div>
<span class="loan-report__status loan-report__status--${loan.status}">
${loan.status === 'paid' ? t('budget.loanStatusPaid') : t('budget.loanStatusActive')}
</span>
</div>
<div class="loan-report__grid">
<div><span>${t('budget.loanAmountLabel')}</span><strong>${formatAmount(loan.total_amount)}</strong></div>
<div><span>${t('budget.loanRemainingAmount')}</span><strong>${formatAmount(loan.remaining_amount)}</strong></div>
<div><span>${t('budget.loanPaidAmount')}</span><strong>${formatAmount(loan.paid_amount)}</strong></div>
<div><span>${t('budget.loanRemainingInstallments')}</span><strong>${loan.remaining_installments}</strong></div>
</div>
<div class="loan-report__section-title">${t('budget.loanTransactions')}</div>
${payments.length ? `
<div class="loan-report__transactions">
${payments.map((payment) => `
<div class="budget-loan-transaction">
<div>
<strong>${t('budget.loanInstallmentNumber', { number: payment.installment_number, total: loan.installment_count })}</strong>
<span>${formatEntryDate(payment.paid_date)}</span>
</div>
<div>
<strong>${formatAmount(payment.amount)}</strong>
</div>
</div>
`).join('')}
</div>
` : `<div class="budget-loans__empty">${t('budget.loanNoTransactions')}</div>`}
</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
<div></div>
<button class="btn btn--primary" id="loan-report-close">${t('common.close')}</button>
</div>`;
openSharedModal({
title: t('budget.loanReportTitle'),
content,
size: 'md',
onSave(panel) {
panel.querySelector('#loan-report-close')?.addEventListener('click', closeModal);
},
});
}
function renderLoanCard(loan) { function renderLoanCard(loan) {
const paidPct = Math.min(100, Math.round((loan.paid_amount / loan.total_amount) * 100)); const paidPct = Math.min(100, Math.round((loan.paid_amount / loan.total_amount) * 100));
const nextDue = loan.next_due_month ? formatMonthLabel(loan.next_due_month) : t('budget.loanPaidStatus'); const nextDue = loan.next_due_month ? formatMonthLabel(loan.next_due_month) : t('budget.loanPaidStatus');
const payDisabled = loan.remaining_installments <= 0 ? 'disabled' : ''; const payDisabled = loan.remaining_installments <= 0 ? 'disabled' : '';
return ` return `
<article class="budget-loan-card"> <article class="budget-loan-card" data-loan-id="${loan.id}">
<div class="budget-loan-card__main"> <div class="budget-loan-card__main">
<div class="budget-loan-card__title-row"> <div class="budget-loan-card__title-row">
<div class="budget-loan-card__title">${esc(loan.title)}</div> <div class="budget-loan-card__title">${esc(loan.title)}</div>
+163 -5
View File
@@ -86,7 +86,7 @@
.budget-tab { .budget-tab {
min-height: 34px; min-height: 34px;
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: 0; border: 1px solid transparent;
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
background: transparent; background: transparent;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -95,11 +95,34 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
.budget-tab[data-tab="budget"] {
color: var(--module-budget);
}
.budget-tab[data-tab="loans"] {
color: var(--color-info);
}
.budget-tab[data-tab="budget"]:not(.budget-tab--active) {
background: color-mix(in srgb, var(--module-budget) 10%, transparent);
}
.budget-tab[data-tab="loans"]:not(.budget-tab--active) {
background: color-mix(in srgb, var(--color-info) 10%, transparent);
}
.budget-tab--active { .budget-tab--active {
background: var(--module-accent);
color: var(--color-text-on-accent); color: var(--color-text-on-accent);
} }
.budget-tab[data-tab="budget"].budget-tab--active {
background: var(--module-budget);
}
.budget-tab[data-tab="loans"].budget-tab--active {
background: var(--color-info);
}
.budget-tab-panel { .budget-tab-panel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -269,8 +292,30 @@
margin-top: 2px; margin-top: 2px;
} }
.budget-loans__add { .budget-loans__filters {
flex-shrink: 0; display: flex;
gap: var(--space-1);
padding: 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
}
.budget-loans__filter {
min-height: 30px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
}
.budget-loans__filter--active {
background: var(--color-info);
color: var(--color-text-on-accent);
} }
.budget-loans__stats { .budget-loans__stats {
@@ -328,6 +373,13 @@
padding: var(--space-3); padding: var(--space-3);
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
}
.budget-loan-card:hover {
border-color: var(--color-info);
background: var(--color-surface-2);
} }
.budget-loan-card__main { .budget-loan-card__main {
@@ -402,6 +454,107 @@
gap: var(--space-2); gap: var(--space-2);
} }
.budget-loan-transactions {
margin-top: var(--space-4);
border-top: 1px solid var(--color-border);
padding-top: var(--space-3);
}
.budget-loan-transactions__title,
.loan-report__section-title {
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.budget-loan-transactions__list,
.loan-report__transactions {
display: grid;
gap: var(--space-2);
}
.budget-loan-transaction {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
}
.budget-loan-transaction span {
display: block;
margin-top: 2px;
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.budget-loan-transaction > div:last-child {
text-align: right;
}
.loan-report__hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface-2);
}
.loan-report__borrower {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.loan-report__title {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
margin-top: 2px;
}
.loan-report__status {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
}
.loan-report__status--active {
background: var(--color-info);
color: var(--color-text-on-accent);
}
.loan-report__status--paid {
background: var(--color-success);
color: var(--color-text-on-accent);
}
.loan-report__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin: var(--space-3) 0;
}
.loan-report__grid > div {
padding: var(--space-3);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
}
.loan-report__grid span {
display: block;
color: var(--color-text-secondary);
font-size: var(--text-xs);
margin-bottom: 2px;
}
.budget-page .form-grid-2 { .budget-page .form-grid-2 {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -641,10 +794,15 @@
} }
.amount-type-btn--loan.amount-type-btn--active { .amount-type-btn--loan.amount-type-btn--active {
background-color: var(--module-accent); background-color: var(--module-budget);
color: var(--color-text-on-accent); color: var(--color-text-on-accent);
} }
.modal-panel .js-entry-field[hidden],
.modal-panel #bm-loan-fields[hidden] {
display: none !important;
}
.budget-field-header { .budget-field-header {
display: flex; display: flex;
align-items: center; align-items: center;
+11
View File
@@ -889,6 +889,17 @@ router.put('/:id', (req, res) => {
if (linkedPayment && amount !== undefined && Number(amount) <= 0) { if (linkedPayment && amount !== undefined && Number(amount) <= 0) {
return res.status(400).json({ error: 'Loan repayment entries must remain income.', code: 400 }); return res.status(400).json({ error: 'Loan repayment entries must remain income.', code: 400 });
} }
if (linkedPayment && amount !== undefined) {
const loan = db.get().prepare('SELECT total_amount FROM budget_loans WHERE id = ?').get(linkedPayment.loan_id);
const otherPaid = db.get().prepare(`
SELECT COALESCE(SUM(amount), 0) AS total
FROM budget_loan_payments
WHERE loan_id = ? AND id != ?
`).get(linkedPayment.loan_id, linkedPayment.id).total;
if (Number(amount) - (Number(loan?.total_amount || 0) - Number(otherPaid || 0)) > 0.005) {
return res.status(400).json({ error: 'Amount cannot be greater than the remaining loan amount.', code: 400 });
}
}
const nextCategory = category ?? entry.category; const nextCategory = category ?? entry.category;
const subcategory = requestedSubcategory !== undefined || category !== undefined const subcategory = requestedSubcategory !== undefined || category !== undefined
? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory) ? validateSubcategory(nextCategory, requestedSubcategory ?? entry.subcategory)