fina/app/static/js/budget.js

317 lines
11 KiB
JavaScript
Raw Normal View History

2025-12-26 00:52:56 +00:00
/**
* Budget Alerts Dashboard Module
* Displays budget warnings, progress bars, and alerts
*/
class BudgetDashboard {
constructor() {
this.budgetData = null;
this.refreshInterval = null;
}
/**
* Initialize budget dashboard
*/
async init() {
await this.loadBudgetStatus();
this.renderBudgetBanner();
this.attachEventListeners();
// Refresh every 5 minutes
this.refreshInterval = setInterval(() => {
this.loadBudgetStatus();
}, 5 * 60 * 1000);
}
/**
* Load budget status from API
*/
async loadBudgetStatus() {
try {
this.budgetData = await window.apiCall('/api/budget/status', 'GET');
this.renderBudgetBanner();
this.updateCategoryBudgets();
} catch (error) {
console.error('Error loading budget status:', error);
}
}
/**
* Render budget alert banner at top of dashboard
*/
renderBudgetBanner() {
const existingBanner = document.getElementById('budgetAlertBanner');
if (existingBanner) {
existingBanner.remove();
}
if (!this.budgetData || !this.budgetData.active_alerts || this.budgetData.active_alerts.length === 0) {
return;
}
const mostSevere = this.budgetData.active_alerts[0];
const banner = document.createElement('div');
banner.id = 'budgetAlertBanner';
banner.className = `mb-6 rounded-lg p-4 ${this.getBannerClass(mostSevere.level)}`;
let message = '';
let icon = '';
switch (mostSevere.level) {
case 'warning':
icon = '<span class="material-symbols-outlined">warning</span>';
break;
case 'danger':
case 'exceeded':
icon = '<span class="material-symbols-outlined">error</span>';
break;
}
if (mostSevere.type === 'overall') {
message = window.getTranslation('budget.overallWarning')
.replace('{percentage}', mostSevere.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(mostSevere.spent))
.replace('{budget}', window.formatCurrency(mostSevere.budget));
} else if (mostSevere.type === 'category') {
message = window.getTranslation('budget.categoryWarning')
.replace('{category}', mostSevere.category_name)
.replace('{percentage}', mostSevere.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(mostSevere.spent))
.replace('{budget}', window.formatCurrency(mostSevere.budget));
}
banner.innerHTML = `
<div class="flex items-start">
<div class="flex-shrink-0 text-2xl mr-3">
${icon}
</div>
<div class="flex-1">
<h3 class="font-semibold mb-1">${window.getTranslation('budget.alert')}</h3>
<p class="text-sm">${message}</p>
${this.budgetData.active_alerts.length > 1 ? `
<button onclick="budgetDashboard.showAllAlerts()" class="mt-2 text-sm underline">
${window.getTranslation('budget.viewAllAlerts')} (${this.budgetData.active_alerts.length})
</button>
` : ''}
</div>
<button onclick="budgetDashboard.dismissBanner()" class="flex-shrink-0 ml-3">
<span class="material-symbols-outlined">close</span>
</button>
</div>
`;
// Insert at the top of main content
const mainContent = document.querySelector('main') || document.querySelector('.container');
if (mainContent && mainContent.firstChild) {
mainContent.insertBefore(banner, mainContent.firstChild);
}
}
/**
* Get banner CSS classes based on alert level
*/
getBannerClass(level) {
switch (level) {
case 'warning':
return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
case 'danger':
return 'bg-orange-100 text-orange-800 border border-orange-300';
case 'exceeded':
return 'bg-red-100 text-red-800 border border-red-300';
default:
return 'bg-blue-100 text-blue-800 border border-blue-300';
}
}
/**
* Dismiss budget banner (hide for 1 hour)
*/
dismissBanner() {
const banner = document.getElementById('budgetAlertBanner');
if (banner) {
banner.remove();
}
// Store dismissal timestamp
localStorage.setItem('budgetBannerDismissed', Date.now().toString());
}
/**
* Check if banner should be shown (not dismissed in last hour)
*/
shouldShowBanner() {
const dismissed = localStorage.getItem('budgetBannerDismissed');
if (!dismissed) return true;
const dismissedTime = parseInt(dismissed);
const oneHour = 60 * 60 * 1000;
return Date.now() - dismissedTime > oneHour;
}
/**
* Show modal with all active alerts
*/
showAllAlerts() {
if (!this.budgetData || !this.budgetData.active_alerts) return;
const modal = document.createElement('div');
modal.id = 'allAlertsModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
const alertsList = this.budgetData.active_alerts.map(alert => {
let message = '';
if (alert.type === 'overall') {
message = window.getTranslation('budget.overallWarning')
.replace('{percentage}', alert.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(alert.spent))
.replace('{budget}', window.formatCurrency(alert.budget));
} else {
message = window.getTranslation('budget.categoryWarning')
.replace('{category}', alert.category_name)
.replace('{percentage}', alert.percentage.toFixed(0))
.replace('{spent}', window.formatCurrency(alert.spent))
.replace('{budget}', window.formatCurrency(alert.budget));
}
return `
<div class="p-3 rounded-lg mb-3 ${this.getBannerClass(alert.level)}">
<div class="font-semibold mb-1">${alert.category_name || window.getTranslation('budget.monthlyBudget')}</div>
<div class="text-sm">${message}</div>
<div class="mt-2">
${this.renderProgressBar(alert.percentage, alert.level)}
</div>
</div>
`;
}).join('');
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">${window.getTranslation('budget.activeAlerts')}</h2>
<button onclick="document.getElementById('allAlertsModal').remove()" class="text-gray-500 hover:text-gray-700">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div>
${alertsList}
</div>
<div class="mt-6 flex justify-end">
<button onclick="document.getElementById('allAlertsModal').remove()"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300">
${window.getTranslation('common.close')}
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
/**
* Render a progress bar for budget percentage
*/
renderProgressBar(percentage, level) {
const cappedPercentage = Math.min(percentage, 100);
let colorClass = 'bg-green-500';
switch (level) {
case 'warning':
colorClass = 'bg-yellow-500';
break;
case 'danger':
colorClass = 'bg-orange-500';
break;
case 'exceeded':
colorClass = 'bg-red-500';
break;
}
return `
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${colorClass} h-2.5 rounded-full transition-all duration-300"
style="width: ${cappedPercentage}%"></div>
</div>
<div class="text-xs mt-1 text-right">${percentage.toFixed(0)}%</div>
`;
}
/**
* Update category cards with budget information
*/
updateCategoryBudgets() {
if (!this.budgetData || !this.budgetData.categories) return;
this.budgetData.categories.forEach(category => {
const categoryCard = document.querySelector(`[data-category-id="${category.id}"]`);
if (!categoryCard) return;
// Check if budget info already exists
let budgetInfo = categoryCard.querySelector('.budget-info');
if (!budgetInfo) {
budgetInfo = document.createElement('div');
budgetInfo.className = 'budget-info mt-2';
categoryCard.appendChild(budgetInfo);
}
if (category.budget_status && category.budget_status.budget) {
const status = category.budget_status;
budgetInfo.innerHTML = `
<div class="text-xs text-gray-600 mb-1">
${window.formatCurrency(status.spent)} / ${window.formatCurrency(status.budget)}
</div>
${this.renderProgressBar(status.percentage, status.alert_level)}
`;
} else {
budgetInfo.innerHTML = '';
}
});
}
/**
* Attach event listeners
*/
attachEventListeners() {
// Listen for expense changes to refresh budget
document.addEventListener('expenseCreated', () => {
this.loadBudgetStatus();
});
document.addEventListener('expenseUpdated', () => {
this.loadBudgetStatus();
});
document.addEventListener('expenseDeleted', () => {
this.loadBudgetStatus();
});
}
/**
* Cleanup on destroy
*/
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
// Create global instance
window.budgetDashboard = new BudgetDashboard();
// Initialize on dashboard page
if (window.location.pathname === '/dashboard' || window.location.pathname === '/') {
document.addEventListener('DOMContentLoaded', () => {
window.budgetDashboard.init();
});
}