// Dashboard JavaScript let categoryChart, monthlyChart; // Comprehensive category icons list (Material Symbols) const CATEGORY_ICONS = { // Finance & Money 'wallet': 'Wallet', 'savings': 'Savings', 'account_balance': 'Bank', 'credit_card': 'Credit Card', 'payments': 'Payments', 'currency_exchange': 'Exchange', 'attach_money': 'Money', 'price_check': 'Price Check', 'receipt': 'Receipt', 'receipt_long': 'Receipt Long', // Housing & Home 'home': 'Home', 'apartment': 'Apartment', 'house': 'House', 'real_estate_agent': 'Mortgage', 'cottage': 'Cottage', 'roofing': 'Roofing', 'foundation': 'Foundation', 'construction': 'Construction', 'home_repair_service': 'Home Repair', 'plumbing': 'Plumbing', 'electrical_services': 'Electrical', 'hvac': 'HVAC', 'carpenter': 'Carpenter', // Transportation 'directions_car': 'Car', 'local_gas_station': 'Gas Station', 'local_taxi': 'Taxi', 'commute': 'Commute', 'directions_bus': 'Bus', 'train': 'Train', 'subway': 'Subway', 'directions_bike': 'Bike', 'two_wheeler': 'Motorcycle', 'flight': 'Flight', 'local_shipping': 'Delivery', 'local_parking': 'Parking', 'car_repair': 'Car Repair', 'oil_barrel': 'Oil/Fuel', 'tire_repair': 'Tire Repair', // Food & Dining 'restaurant': 'Restaurant', 'local_dining': 'Dining', 'fastfood': 'Fast Food', 'local_pizza': 'Pizza', 'local_cafe': 'Cafe', 'local_bar': 'Bar', 'liquor': 'Liquor', 'dinner_dining': 'Dinner', 'lunch_dining': 'Lunch', 'breakfast_dining': 'Breakfast', 'ramen_dining': 'Ramen', 'set_meal': 'Set Meal', 'takeout_dining': 'Takeout', 'room_service': 'Room Service', 'bakery_dining': 'Bakery', 'icecream': 'Ice Cream', 'cake': 'Cake', // Shopping & Retail 'shopping_cart': 'Shopping', 'shopping_bag': 'Shopping Bag', 'store': 'Store', 'storefront': 'Shop', 'local_grocery_store': 'Grocery', 'local_mall': 'Mall', 'local_convenience_store': 'Convenience', 'checkroom': 'Clothing', 'dry_cleaning': 'Dry Cleaning', 'laundry': 'Laundry', // Entertainment & Leisure 'movie': 'Movies', 'theaters': 'Theater', 'sports_esports': 'Gaming', 'casino': 'Casino', 'nightlife': 'Nightlife', 'sports_bar': 'Sports Bar', 'pool': 'Pool', 'sports': 'Sports', 'sports_soccer': 'Soccer', 'sports_basketball': 'Basketball', 'sports_tennis': 'Tennis', 'golf_course': 'Golf', 'fitness_center': 'Gym', 'hiking': 'Hiking', 'kayaking': 'Kayaking', 'surfing': 'Surfing', 'sailing': 'Sailing', 'downhill_skiing': 'Skiing', 'snowboarding': 'Snowboarding', 'music_note': 'Music', 'headphones': 'Headphones', 'videogame_asset': 'Video Games', 'toys': 'Toys', 'celebration': 'Celebration', 'festival': 'Festival', // Health & Medical 'medical_services': 'Medical', 'local_hospital': 'Hospital', 'local_pharmacy': 'Pharmacy', 'medication': 'Medication', 'vaccines': 'Vaccines', 'health_and_safety': 'Health', 'psychology': 'Mental Health', 'dental_services': 'Dental', 'ophthalmology': 'Eye Care', 'healing': 'Healing', 'monitor_heart': 'Heart Health', // Personal Care & Beauty 'spa': 'Spa', 'self_improvement': 'Self Care', 'face': 'Face Care', 'hair_dryer': 'Hair Dryer', 'content_cut': 'Haircut', 'cosmetics': 'Cosmetics', 'perfume': 'Perfume', // Education & Work 'school': 'Education', 'work': 'Work', 'business': 'Business', 'laptop': 'Laptop', 'computer': 'Computer', 'book': 'Books', 'menu_book': 'Study', 'library_books': 'Library', 'article': 'Article', 'science': 'Science', 'engineering': 'Engineering', // Utilities & Bills 'bolt': 'Electricity', 'water_drop': 'Water', 'cell_tower': 'Internet', 'phone': 'Phone', 'wifi': 'WiFi', 'router': 'Router', 'tv': 'TV/Cable', 'satellite': 'Satellite', 'propane_tank': 'Propane', 'heat': 'Heating', 'ac_unit': 'Air Conditioning', // Insurance & Financial Services 'shield': 'Insurance', 'health_and_safety': 'Health Insurance', 'verified_user': 'Life Insurance', 'lock': 'Security', 'gavel': 'Legal', 'balance': 'Accounting', // Pets & Animals 'pets': 'Pets', 'cruelty_free': 'Pet Care', // Tobacco & Vices 'smoking_rooms': 'Smoking', 'vaping_rooms': 'Vaping', // Family & Children 'family_restroom': 'Family', 'child_care': 'Childcare', 'baby_changing_station': 'Baby Care', 'toys': 'Kids Toys', 'school': 'School', 'backpack': 'School Supplies', // Gifts & Donations 'redeem': 'Gifts', 'card_giftcard': 'Gift Card', 'volunteer_activism': 'Donations', 'favorite': 'Charity', // Tech & Electronics 'phone_iphone': 'Smartphone', 'tablet': 'Tablet', 'watch': 'Watch', 'headset': 'Headset', 'speaker': 'Speaker', 'keyboard': 'Keyboard', 'mouse': 'Mouse', 'print': 'Printer', 'camera': 'Camera', 'videocam': 'Video Camera', // Travel & Vacation 'luggage': 'Luggage', 'hotel': 'Hotel', 'beach_access': 'Beach', 'park': 'Park', 'nature': 'Nature', 'explore': 'Explore', 'tour': 'Tour', 'map': 'Map', 'travel_explore': 'Travel', // Miscellaneous 'category': 'General', 'folder': 'Category', 'label': 'Label', 'sell': 'Sale', 'new_releases': 'New', 'star': 'Favorite', 'grade': 'Premium', 'workspace_premium': 'Premium', 'diamond': 'Luxury', 'emergency': 'Emergency', 'priority_high': 'Priority', 'tips_and_updates': 'Tips', 'lightbulb': 'Idea', 'eco': 'Eco/Green', 'recycling': 'Recycling', 'compost': 'Compost', 'local_florist': 'Flowers', 'pets': 'Pets', 'bug_report': 'Misc' }; let currentIconTarget = null; // Helper function to validate and sanitize icon names function getValidIcon(iconName) { // If the icon exists in our CATEGORY_ICONS list, return it if (iconName && CATEGORY_ICONS[iconName]) { return iconName; } // If it's a string, try converting to lowercase if (iconName && typeof iconName === 'string') { const lowerIcon = iconName.toLowerCase(); if (CATEGORY_ICONS[lowerIcon]) { return lowerIcon; } } // Default fallback return 'category'; } // Load dashboard data async function loadDashboardData() { try { const stats = await apiCall('/api/dashboard-stats'); // Store user currency globally for use across functions window.userCurrency = stats.currency || 'GBP'; // Ensure we have valid data with defaults const totalSpent = parseFloat(stats.total_spent || 0); const totalIncome = parseFloat(stats.total_income || 0); const profitLoss = parseFloat(stats.profit_loss || 0); const activeCategories = parseInt(stats.active_categories || 0); const totalTransactions = parseInt(stats.total_transactions || 0); const categoryBreakdown = stats.category_breakdown || []; const monthlyData = stats.monthly_data || []; // Update KPI cards document.getElementById('total-spent').textContent = formatCurrency(totalSpent, window.userCurrency); // Update income card if exists const incomeElement = document.getElementById('total-income'); if (incomeElement) { incomeElement.textContent = formatCurrency(totalIncome, window.userCurrency); } // Update profit/loss card if exists const profitElement = document.getElementById('profit-loss'); if (profitElement) { profitElement.textContent = formatCurrency(Math.abs(profitLoss), window.userCurrency); const profitCard = profitElement.closest('.bg-white, .dark\\:bg-card-dark'); if (profitCard) { if (profitLoss >= 0) { profitCard.classList.add('border-green-500/20'); profitCard.classList.remove('border-red-500/20'); } else { profitCard.classList.add('border-red-500/20'); profitCard.classList.remove('border-green-500/20'); } } } // Update total transactions (active-categories element no longer exists) const totalTransactionsEl = document.getElementById('total-transactions'); if (totalTransactionsEl) { totalTransactionsEl.textContent = totalTransactions; } // Update percent change const percentChange = document.getElementById('percent-change'); const percentChangeValue = parseFloat(stats.percent_change || 0); const isPositive = percentChangeValue >= 0; percentChange.className = `${isPositive ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'} text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1`; percentChange.innerHTML = ` ${isPositive ? 'trending_up' : 'trending_down'} ${Math.abs(percentChangeValue).toFixed(1)}% `; // Load charts with validated data loadCategoryChart(categoryBreakdown); loadMonthlyChart(monthlyData); // Load category cards loadCategoryCards(categoryBreakdown, totalSpent); // Load recent transactions loadRecentTransactions(); } catch (error) { console.error('Failed to load dashboard data:', error); } } // Category pie chart with CSS conic-gradient (beautiful & lightweight) function loadCategoryChart(data) { const pieChart = document.getElementById('pie-chart'); const pieTotal = document.getElementById('pie-total'); const pieLegend = document.getElementById('pie-legend'); if (!pieChart || !pieTotal || !pieLegend) return; if (!data || data.length === 0) { pieChart.style.background = 'conic-gradient(#233648 0% 100%)'; pieTotal.textContent = '0 lei'; pieLegend.innerHTML = '

' + (window.getTranslation ? window.getTranslation('dashboard.noData', 'No data available') : 'No data available') + '

'; return; } // Calculate total and get user currency from API response (stored globally) const total = data.reduce((sum, cat) => sum + parseFloat(cat.total || 0), 0); const userCurrency = window.userCurrency || 'RON'; pieTotal.textContent = formatCurrency(total, userCurrency); // Generate conic gradient segments let currentPercent = 0; const gradientSegments = data.map(cat => { const percent = total > 0 ? (parseFloat(cat.total || 0) / total) * 100 : 0; const segment = `${cat.color} ${currentPercent}% ${currentPercent + percent}%`; currentPercent += percent; return segment; }); // Apply gradient with smooth transitions pieChart.style.background = `conic-gradient(${gradientSegments.join(', ')})`; // Generate compact legend for 12-14 categories pieLegend.innerHTML = data.map(cat => { const percent = total > 0 ? ((parseFloat(cat.total || 0) / total) * 100).toFixed(1) : 0; return `
${cat.name} ${percent}%
`; }).join(''); } // Monthly bar chart - with income vs expenses comparison function loadMonthlyChart(data) { const ctx = document.getElementById('monthly-chart').getContext('2d'); if (monthlyChart) { monthlyChart.destroy(); } // Check if we have income data (new format) const hasIncome = data.length > 0 && data[0].hasOwnProperty('income'); const datasets = hasIncome ? [ { label: window.getTranslation ? window.getTranslation('nav.income', 'Income') : 'Income', data: data.map(d => d.income || 0), backgroundColor: '#10b981', borderRadius: 6, barPercentage: 0.5, categoryPercentage: 0.7 }, { label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Expenses') : 'Expenses', data: data.map(d => d.expenses || d.total || 0), backgroundColor: '#ef4444', borderRadius: 6, barPercentage: 0.5, categoryPercentage: 0.7 } ] : [{ label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Spending') : 'Spending', data: data.map(d => d.total || d.expenses || 0), backgroundColor: '#2b8cee', borderRadius: 6, barPercentage: 0.5, categoryPercentage: 0.7 }]; monthlyChart = new Chart(ctx, { type: 'bar', data: { labels: data.map(d => d.month), datasets: datasets }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: hasIncome, position: 'top', align: 'end', labels: { color: document.documentElement.classList.contains('dark') ? '#ffffff' : '#1a2632', font: { size: 11 }, boxWidth: 12, boxHeight: 12, padding: 10 } }, tooltip: { backgroundColor: document.documentElement.classList.contains('dark') ? '#1a2632' : '#ffffff', titleColor: document.documentElement.classList.contains('dark') ? '#ffffff' : '#1a2632', bodyColor: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b', borderColor: document.documentElement.classList.contains('dark') ? '#233648' : '#e2e8f0', borderWidth: 1, padding: 12, displayColors: true, callbacks: { label: function(context) { const userCurrency = window.userCurrency || 'GBP'; return context.dataset.label + ': ' + formatCurrency(context.parsed.y, userCurrency); } } } }, scales: { y: { beginAtZero: true, ticks: { color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b', font: { size: 11 }, maxTicksLimit: 6 }, grid: { color: document.documentElement.classList.contains('dark') ? '#233648' : '#e2e8f0', drawBorder: false }, border: { display: false } }, x: { ticks: { color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b', font: { size: 10 }, autoSkip: false, maxRotation: 0, minRotation: 0 }, grid: { display: false }, border: { display: false } } }, layout: { padding: { left: 5, right: 5, top: 5, bottom: 0 } } } }); } // Load recent transactions async function loadRecentTransactions() { try { const data = await apiCall('/api/recent-transactions?limit=5'); const container = document.getElementById('recent-transactions'); if (data.transactions.length === 0) { const noTransText = window.getTranslation ? window.getTranslation('dashboard.noTransactions', 'No transactions yet') : 'No transactions yet'; container.innerHTML = `

${noTransText}

`; return; } container.innerHTML = data.transactions.map(tx => `
payments

${tx.description}

${tx.category_name} • ${formatDate(tx.date)}

${formatCurrency(tx.amount, window.userCurrency || 'RON')}

${tx.tags.length > 0 ? `

${tx.tags.join(', ')}

` : ''}
`).join(''); } catch (error) { console.error('Failed to load transactions:', error); } } // Format currency helper function formatCurrency(amount, currency) { const symbols = { 'USD': '$', 'EUR': '€', 'GBP': '£', 'RON': 'lei' }; const symbol = symbols[currency] || currency; const formattedAmount = parseFloat(amount || 0).toFixed(2); if (currency === 'RON') { return `${formattedAmount} ${symbol}`; } return `${symbol}${formattedAmount}`; } // Load category cards with drag and drop (with NaN prevention) function loadCategoryCards(categoryBreakdown, totalSpent) { const container = document.getElementById('category-cards'); if (!container) return; // Validate data if (!categoryBreakdown || !Array.isArray(categoryBreakdown) || categoryBreakdown.length === 0) { container.innerHTML = '

' + (window.getTranslation ? window.getTranslation('dashboard.noCategories', 'No categories yet') : 'No categories yet') + '

'; return; } // Ensure totalSpent is a valid number const validTotalSpent = parseFloat(totalSpent || 0); container.innerHTML = categoryBreakdown.map(cat => { const total = parseFloat(cat.total || 0); const count = parseInt(cat.count || 0); const percentage = validTotalSpent > 0 ? ((total / validTotalSpent) * 100).toFixed(1) : 0; const percentageCapped = Math.min(parseFloat(percentage), 100); // Cap at 100% for display const icon = getValidIcon(cat.icon); // Validate and sanitize icon from database // Budget status if available let budgetDisplay = ''; let mainProgressColor = cat.color; // Default to category color if (cat.budget_status && cat.budget_status.budget) { const budgetStatus = cat.budget_status; const budgetPercentage = Math.min(budgetStatus.percentage, 100); let budgetColor = '#10b981'; // green by default if (budgetStatus.alert_level === 'warning') { budgetColor = '#eab308'; // yellow mainProgressColor = '#eab308'; // Update main bar too } else if (budgetStatus.alert_level === 'danger') { budgetColor = '#f97316'; // orange mainProgressColor = '#f97316'; // Update main bar too } else if (budgetStatus.alert_level === 'exceeded') { budgetColor = '#ef4444'; // red mainProgressColor = '#ef4444'; // Update main bar too } budgetDisplay = `
${window.getTranslation('budget.budgetAmount', 'Budget')} ${formatCurrency(budgetStatus.spent, window.userCurrency)} / ${formatCurrency(budgetStatus.budget, window.userCurrency)}
${budgetStatus.percentage.toFixed(0)}%
`; } return `
${getValidIcon(icon)}

${cat.name}

${count} ${count === 1 ? (window.getTranslation ? window.getTranslation('transactions.transaction', 'transaction') : 'transaction') : (window.getTranslation ? window.getTranslation('transactions.transactions', 'transactions') : 'transactions')}

${percentage}%

${formatCurrency(total, window.userCurrency || 'RON')}

${budgetDisplay}
`; }).join(''); // Enable drag and drop on category cards enableCategoryCardsDragDrop(); } // Enable drag and drop for category cards on dashboard let draggedCard = null; function enableCategoryCardsDragDrop() { const cards = document.querySelectorAll('.category-card'); cards.forEach(card => { // Drag start card.addEventListener('dragstart', function(e) { draggedCard = this; this.style.opacity = '0.5'; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', this.innerHTML); }); // Drag over card.addEventListener('dragover', function(e) { if (e.preventDefault) { e.preventDefault(); } e.dataTransfer.dropEffect = 'move'; if (draggedCard !== this) { const container = document.getElementById('category-cards'); const allCards = [...container.querySelectorAll('.category-card')]; const draggedIndex = allCards.indexOf(draggedCard); const targetIndex = allCards.indexOf(this); if (draggedIndex < targetIndex) { this.parentNode.insertBefore(draggedCard, this.nextSibling); } else { this.parentNode.insertBefore(draggedCard, this); } } return false; }); // Drag enter card.addEventListener('dragenter', function(e) { if (draggedCard !== this) { this.style.borderColor = '#2b8cee'; } }); // Drag leave card.addEventListener('dragleave', function(e) { this.style.borderColor = ''; }); // Drop card.addEventListener('drop', function(e) { if (e.stopPropagation) { e.stopPropagation(); } this.style.borderColor = ''; return false; }); // Drag end card.addEventListener('dragend', function(e) { this.style.opacity = '1'; // Reset all borders const allCards = document.querySelectorAll('.category-card'); allCards.forEach(c => c.style.borderColor = ''); // Save new order saveDashboardCategoryOrder(); }); // Touch support for mobile card.addEventListener('touchstart', handleTouchStart, {passive: false}); card.addEventListener('touchmove', handleTouchMove, {passive: false}); card.addEventListener('touchend', handleTouchEnd, {passive: false}); }); } // Touch event handlers for mobile drag and drop with hold-to-drag let touchStartPos = null; let touchedCard = null; let holdTimer = null; let isDraggingEnabled = false; const HOLD_DURATION = 500; // 500ms hold required to start dragging function handleTouchStart(e) { // Don't interfere with scrolling initially touchedCard = this; touchStartPos = { x: e.touches[0].clientX, y: e.touches[0].clientY }; isDraggingEnabled = false; // Start hold timer holdTimer = setTimeout(() => { // After holding, enable dragging isDraggingEnabled = true; if (touchedCard) { touchedCard.style.opacity = '0.5'; touchedCard.style.transform = 'scale(1.05)'; // Haptic feedback if available if (navigator.vibrate) { navigator.vibrate(50); } } }, HOLD_DURATION); } function handleTouchMove(e) { if (!touchedCard || !touchStartPos) return; const touch = e.touches[0]; const deltaX = Math.abs(touch.clientX - touchStartPos.x); const deltaY = Math.abs(touch.clientY - touchStartPos.y); // If moved too much before hold timer completes, cancel hold if (!isDraggingEnabled && (deltaX > 10 || deltaY > 10)) { clearTimeout(holdTimer); touchedCard = null; touchStartPos = null; return; } // Only allow dragging if hold timer completed if (!isDraggingEnabled) return; // Prevent scrolling when dragging e.preventDefault(); const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); const targetCard = elementBelow?.closest('.category-card'); if (targetCard && targetCard !== touchedCard) { const container = document.getElementById('category-cards'); const allCards = [...container.querySelectorAll('.category-card')]; const touchedIndex = allCards.indexOf(touchedCard); const targetIndex = allCards.indexOf(targetCard); if (touchedIndex < targetIndex) { targetCard.parentNode.insertBefore(touchedCard, targetCard.nextSibling); } else { targetCard.parentNode.insertBefore(touchedCard, targetCard); } } } function handleTouchEnd(e) { // Clear hold timer if touch ended early clearTimeout(holdTimer); if (touchedCard) { touchedCard.style.opacity = '1'; touchedCard.style.transform = ''; // Only save if dragging actually happened if (isDraggingEnabled) { saveDashboardCategoryOrder(); } touchedCard = null; touchStartPos = null; isDraggingEnabled = false; } } // Save dashboard category card order async function saveDashboardCategoryOrder() { const cards = document.querySelectorAll('.category-card'); const reorderedCategories = Array.from(cards).map((card, index) => ({ id: parseInt(card.dataset.categoryId), display_order: index })); try { await apiCall('/api/expenses/categories/reorder', { method: 'PUT', body: JSON.stringify({ categories: reorderedCategories }) }); // Silently save - no notification to avoid disrupting UX during drag } catch (error) { console.error('Failed to save category order:', error); showToast(getTranslation('common.error', 'Failed to save order'), 'error'); } } // Expense modal const expenseModal = document.getElementById('expense-modal'); const addExpenseBtn = document.getElementById('add-expense-btn'); const closeModalBtn = document.getElementById('close-modal'); const expenseForm = document.getElementById('expense-form'); // Load categories for dropdown async function loadCategories() { try { const data = await apiCall('/api/expenses/categories'); const select = expenseForm.querySelector('[name="category_id"]'); const selectText = window.getTranslation ? window.getTranslation('dashboard.selectCategory', 'Select category...') : 'Select category...'; // Map category names to translation keys const categoryTranslations = { 'Food & Dining': 'categories.foodDining', 'Transportation': 'categories.transportation', 'Shopping': 'categories.shopping', 'Entertainment': 'categories.entertainment', 'Bills & Utilities': 'categories.billsUtilities', 'Healthcare': 'categories.healthcare', 'Education': 'categories.education', 'Other': 'categories.other' }; select.innerHTML = `` + data.categories.map(cat => { const translationKey = categoryTranslations[cat.name]; const translatedName = translationKey && window.getTranslation ? window.getTranslation(translationKey, cat.name) : cat.name; return ``; }).join(''); } catch (error) { console.error('Failed to load categories:', error); } } // Open modal addExpenseBtn.addEventListener('click', () => { expenseModal.classList.remove('hidden'); loadCategories(); // Set today's date as default const dateInput = expenseForm.querySelector('[name="date"]'); dateInput.value = new Date().toISOString().split('T')[0]; }); // Close modal closeModalBtn.addEventListener('click', () => { expenseModal.classList.add('hidden'); expenseForm.reset(); }); // Close modal on outside click expenseModal.addEventListener('click', (e) => { if (e.target === expenseModal) { expenseModal.classList.add('hidden'); expenseForm.reset(); } }); // Add tag suggestion container after description field const descInput = document.getElementById('expense-description'); if (descInput && !document.getElementById('tagSuggestionsContainer')) { const suggestionsDiv = document.createElement('div'); suggestionsDiv.id = 'tagSuggestionsContainer'; suggestionsDiv.className = 'mt-2 hidden'; suggestionsDiv.innerHTML = `
lightbulb Suggested Tags
`; descInput.parentElement.appendChild(suggestionsDiv); // Add event listener for real-time suggestions let suggestionTimeout; descInput.addEventListener('input', async (e) => { clearTimeout(suggestionTimeout); const description = e.target.value; if (description.length < 3) { document.getElementById('tagSuggestionsContainer').classList.add('hidden'); return; } suggestionTimeout = setTimeout(async () => { try { const categoryId = categorySelect.value; const response = await apiCall('/api/expenses/suggest-tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description, category_id: categoryId }) }); if (response.success && response.suggested_tags.length > 0) { const container = document.getElementById('tagSuggestionsContainer'); const list = document.getElementById('suggestedTagsList'); list.innerHTML = ''; response.suggested_tags.forEach(tag => { const badge = document.createElement('span'); badge.className = 'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer transition-all hover:brightness-110'; badge.style.backgroundColor = `${tag.color}20`; badge.style.borderColor = `${tag.color}40`; badge.style.color = tag.color; badge.classList.add('border'); badge.innerHTML = ` ${tag.icon} #${tag.name} `; badge.addEventListener('click', () => { // Add to tags field const tagsInput = document.getElementById('expense-tags'); const currentTags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t); if (!currentTags.includes(tag.name)) { currentTags.push(tag.name); tagsInput.value = currentTags.join(', '); } badge.style.opacity = '0.5'; }); list.appendChild(badge); }); container.classList.remove('hidden'); } } catch (error) { console.error('Failed to get tag suggestions:', error); } }, 500); }); } // Submit expense form expenseForm.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(expenseForm); // Convert tags to array const tagsString = formData.get('tags'); if (tagsString) { const tags = tagsString.split(',').map(t => t.trim()).filter(t => t); formData.set('tags', JSON.stringify(tags)); } // Convert date to ISO format const date = new Date(formData.get('date')); formData.set('date', date.toISOString()); try { const result = await apiCall('/api/expenses/', { method: 'POST', body: formData }); if (result.success) { const successMsg = window.getTranslation ? window.getTranslation('dashboard.expenseAdded', 'Expense added successfully!') : 'Expense added successfully!'; showToast(successMsg, 'success'); expenseModal.classList.add('hidden'); expenseForm.reset(); document.getElementById('tagSuggestionsContainer')?.classList.add('hidden'); loadDashboardData(); } } catch (error) { console.error('Failed to add expense:', error); } }); // Category Management Modal const categoryModal = document.getElementById('category-modal'); const manageCategoriesBtn = document.getElementById('manage-categories-btn'); const closeCategoryModal = document.getElementById('close-category-modal'); const addCategoryForm = document.getElementById('add-category-form'); const categoriesList = document.getElementById('categories-list'); let allCategories = []; let draggedElement = null; // Open category modal manageCategoriesBtn.addEventListener('click', async () => { categoryModal.classList.remove('hidden'); await loadCategoriesManagement(); }); // Close category modal closeCategoryModal.addEventListener('click', () => { categoryModal.classList.add('hidden'); loadDashboardData(); // Refresh dashboard }); categoryModal.addEventListener('click', (e) => { if (e.target === categoryModal) { categoryModal.classList.add('hidden'); loadDashboardData(); } }); // Add new category addCategoryForm.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(addCategoryForm); const data = { name: formData.get('name'), color: formData.get('color'), icon: formData.get('icon') || 'category' }; try { const result = await apiCall('/api/expenses/categories', { method: 'POST', body: JSON.stringify(data) }); if (result.success) { showToast(getTranslation('categories.created', 'Category created successfully'), 'success'); addCategoryForm.reset(); await loadCategoriesManagement(); } } catch (error) { console.error('Failed to create category:', error); showToast(getTranslation('common.error', 'An error occurred'), 'error'); } }); // Load categories for management async function loadCategoriesManagement() { try { const data = await apiCall('/api/expenses/categories'); allCategories = data.categories; renderCategoriesList(); } catch (error) { console.error('Failed to load categories:', error); } } // Render categories list with drag and drop function renderCategoriesList() { categoriesList.innerHTML = allCategories.map((cat, index) => `
drag_indicator

${cat.name}

${cat.color} • ${CATEGORY_ICONS[getValidIcon(cat.icon)] || 'General'}

`).join(''); // Add drag and drop event listeners const items = categoriesList.querySelectorAll('.category-item'); items.forEach(item => { item.addEventListener('dragstart', handleDragStart); item.addEventListener('dragover', handleDragOver); item.addEventListener('drop', handleDrop); item.addEventListener('dragend', handleDragEnd); }); } // Drag and drop handlers function handleDragStart(e) { draggedElement = this; this.style.opacity = '0.4'; e.dataTransfer.effectAllowed = 'move'; } function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } e.dataTransfer.dropEffect = 'move'; const afterElement = getDragAfterElement(categoriesList, e.clientY); if (afterElement == null) { categoriesList.appendChild(draggedElement); } else { categoriesList.insertBefore(draggedElement, afterElement); } return false; } function handleDrop(e) { if (e.stopPropagation) { e.stopPropagation(); } return false; } function handleDragEnd(e) { this.style.opacity = '1'; // Update order in backend const items = categoriesList.querySelectorAll('.category-item'); const reorderedCategories = Array.from(items).map((item, index) => ({ id: parseInt(item.dataset.id), display_order: index })); saveCategoriesOrder(reorderedCategories); } function getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll('.category-item:not([style*="opacity: 0.4"])')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } // Save category order async function saveCategoriesOrder(categories) { try { await apiCall('/api/expenses/categories/reorder', { method: 'PUT', body: JSON.stringify({ categories }) }); showToast(getTranslation('categories.reordered', 'Categories reordered successfully'), 'success'); } catch (error) { console.error('Failed to reorder categories:', error); showToast(getTranslation('common.error', 'An error occurred'), 'error'); } } // Show category budget settings modal function showCategoryBudgetModal(categoryId, categoryName, currentBudget, currentThreshold) { const modal = document.createElement('div'); modal.id = 'categoryBudgetModal'; modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'; // Handle null/undefined values properly const budgetValue = currentBudget && currentBudget !== 'null' ? parseFloat(currentBudget) : ''; const threshold = currentThreshold && currentThreshold !== 'null' ? parseFloat(currentThreshold) : 0.9; const thresholdPercent = (threshold * 100).toFixed(0); modal.innerHTML = `

${categoryName}

${window.getTranslation('budget.editBudget', 'Edit Budget')}

50% ${thresholdPercent}% 200%

${window.getTranslation('budget.alertThresholdHelp', 'Get notified when spending reaches this percentage')}

`; document.body.appendChild(modal); // Close on backdrop click modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); // Handle form submission document.getElementById('budgetForm').addEventListener('submit', async (e) => { e.preventDefault(); const budgetAmount = parseFloat(document.getElementById('budgetAmount').value) || null; const thresholdValue = parseFloat(document.getElementById('budgetThreshold').value) / 100; try { await apiCall(`/api/budget/category/${categoryId}/budget`, { method: 'PUT', body: JSON.stringify({ monthly_budget: budgetAmount, budget_alert_threshold: thresholdValue }) }); showToast(window.getTranslation('budget.budgetUpdated', 'Budget updated successfully'), 'success'); modal.remove(); // Reload dashboard to show updated budget loadDashboardData(); // Refresh budget status if (window.budgetDashboard) { window.budgetDashboard.loadBudgetStatus(); } } catch (error) { console.error('Failed to update budget:', error); showToast(window.getTranslation('budget.budgetError', 'Failed to update budget'), 'error'); } }); } // Delete category async function deleteCategory(id) { try { // Try to delete without reassignment first const result = await apiCall(`/api/expenses/categories/${id}`, { method: 'DELETE' }); if (result.success) { showToast(getTranslation('categories.deleted', 'Category deleted successfully'), 'success'); await loadCategoriesManagement(); await loadDashboardData(); } } catch (error) { console.error('Failed to delete category:', error); // If category has expenses, show reassignment options if (error.expense_count && error.requires_reassignment) { const category = allCategories.find(c => c.id === id); const otherCategories = allCategories.filter(c => c.id !== id); if (otherCategories.length === 0) { showToast('Cannot delete the only category with expenses', 'error'); return; } // Create options for select const options = otherCategories.map(cat => `` ).join(''); // Create custom confirmation dialog with category selection const dialog = document.createElement('div'); dialog.className = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4'; dialog.innerHTML = `
warning

Delete Category

This category has ${error.expense_count} expense${error.expense_count > 1 ? 's' : ''}. Where would you like to move ${error.expense_count > 1 ? 'them' : 'it'}?

`; document.body.appendChild(dialog); // Handle cancel dialog.querySelector('#cancel-delete-btn').addEventListener('click', () => { document.body.removeChild(dialog); }); // Handle confirm dialog.querySelector('#confirm-delete-btn').addEventListener('click', async () => { const moveToId = dialog.querySelector('#move-to-category-select').value; if (!moveToId) { showToast('Please select a category to move expenses to', 'error'); return; } try { const deleteResult = await apiCall(`/api/expenses/categories/${id}`, { method: 'DELETE', body: JSON.stringify({ move_to_category_id: parseInt(moveToId) }) }); if (deleteResult.success) { showToast(`Category deleted and ${deleteResult.expenses_moved} expense${deleteResult.expenses_moved > 1 ? 's' : ''} moved`, 'success'); document.body.removeChild(dialog); await loadCategoriesManagement(); await loadDashboardData(); } } catch (deleteError) { console.error('Failed to delete category:', deleteError); showToast(deleteError.message || 'Failed to delete category', 'error'); } }); } else { showToast(error.message || getTranslation('common.error', 'An error occurred'), 'error'); } } } // Make deleteCategory global window.deleteCategory = deleteCategory; // ============== Icon Picker Functions ============== // Open icon picker modal function openIconPicker(target) { currentIconTarget = target; const modal = document.getElementById('icon-picker-modal'); modal.classList.remove('hidden'); // Populate icon grid renderIconGrid(); // Setup search const searchInput = document.getElementById('icon-search'); searchInput.value = ''; searchInput.focus(); searchInput.addEventListener('input', filterIcons); } // Close icon picker modal function closeIconPicker() { const modal = document.getElementById('icon-picker-modal'); modal.classList.add('hidden'); currentIconTarget = null; const searchInput = document.getElementById('icon-search'); searchInput.removeEventListener('input', filterIcons); } // Render icon grid function renderIconGrid(filter = '') { const grid = document.getElementById('icon-grid'); const icons = Object.entries(CATEGORY_ICONS); // Sort icons alphabetically by label const sortedIcons = icons.sort((a, b) => a[1].localeCompare(b[1])); const filteredIcons = filter ? sortedIcons.filter(([icon, label]) => icon.toLowerCase().includes(filter.toLowerCase()) || label.toLowerCase().includes(filter.toLowerCase()) ) : sortedIcons; grid.innerHTML = filteredIcons.map(([icon, label]) => ` `).join(''); } // Filter icons based on search function filterIcons(e) { renderIconGrid(e.target.value); } // Select icon function selectIcon(iconName) { if (!currentIconTarget) return; // Update add form if (currentIconTarget === 'add-form') { document.querySelector('#add-category-form input[name="icon"]').value = iconName; document.getElementById('add-form-icon-preview').textContent = iconName; document.getElementById('add-form-icon-name').textContent = CATEGORY_ICONS[iconName] || iconName; } // Update edit in category list (dynamic) else if (currentIconTarget.startsWith('edit-')) { const categoryId = currentIconTarget.replace('edit-', ''); updateCategoryIcon(categoryId, iconName); } closeIconPicker(); } // Update category icon (inline edit) async function updateCategoryIcon(categoryId, iconName) { try { await apiCall(`/api/expenses/categories/${categoryId}`, { method: 'PUT', body: JSON.stringify({ icon: iconName }) }); showToast('Icon updated successfully', 'success'); await loadCategoriesManagement(); await loadDashboardData(); } catch (error) { console.error('Failed to update icon:', error); showToast('Failed to update icon', 'error'); } } // Make icon picker functions global window.openIconPicker = openIconPicker; window.closeIconPicker = closeIconPicker; window.selectIcon = selectIcon; window.updateCategoryIcon = updateCategoryIcon; // ============== Category Expenses Modal ============== // Show category expenses modal async function showCategoryExpenses(categoryId, categoryName, categoryColor, categoryIcon) { const modal = document.getElementById('category-expenses-modal'); const iconContainer = document.getElementById('modal-category-icon-container'); const icon = document.getElementById('modal-category-icon'); const name = document.getElementById('modal-category-name'); const count = document.getElementById('modal-category-count'); const list = document.getElementById('modal-expenses-list'); const empty = document.getElementById('modal-expenses-empty'); const loading = document.getElementById('modal-expenses-loading'); // Set category info iconContainer.style.background = categoryColor; icon.textContent = getValidIcon(categoryIcon); name.textContent = categoryName; // Show modal and loading modal.classList.remove('hidden'); list.classList.add('hidden'); empty.classList.add('hidden'); loading.classList.remove('hidden'); try { // Fetch expenses for this category const data = await apiCall(`/api/expenses/?category_id=${categoryId}&per_page=100`); loading.classList.add('hidden'); if (!data.expenses || data.expenses.length === 0) { empty.classList.remove('hidden'); count.textContent = window.getTranslation('categories.noExpenses', 'No expenses in this category'); return; } // Update count const total = data.total || data.expenses.length; count.textContent = `${total} ${total === 1 ? (window.getTranslation('transactions.transaction', 'transaction')) : (window.getTranslation('transactions.transactions', 'transactions'))}`; // Render expenses list.innerHTML = data.expenses.map(exp => { const expDate = new Date(exp.date); const formattedDate = formatDate(exp.date); return `
${getValidIcon(categoryIcon)}

${exp.description}

${formattedDate}

${exp.tags && exp.tags.length > 0 ? `
${exp.tags.map(tag => `${tag}`).join('')}
` : ''}

${formatCurrency(exp.amount, exp.currency || window.userCurrency)}

${exp.receipt_path ? `receipt` : ''}
`; }).join(''); list.classList.remove('hidden'); } catch (error) { console.error('Failed to load category expenses:', error); loading.classList.add('hidden'); list.innerHTML = `
error

${window.getTranslation('common.error', 'Failed to load expenses')}

`; list.classList.remove('hidden'); } } // Close category expenses modal function closeCategoryExpensesModal() { const modal = document.getElementById('category-expenses-modal'); modal.classList.add('hidden'); } // Make functions global window.showCategoryExpenses = showCategoryExpenses; window.closeCategoryExpensesModal = closeCategoryExpensesModal; // Initialize dashboard document.addEventListener('DOMContentLoaded', () => { loadDashboardData(); // Refresh data every 5 minutes setInterval(loadDashboardData, 5 * 60 * 1000); });