1595 lines
64 KiB
JavaScript
1595 lines
64 KiB
JavaScript
|
|
// 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 = `
|
||
|
|
<span class="material-symbols-outlined text-[14px]">${isPositive ? 'trending_up' : 'trending_down'}</span>
|
||
|
|
${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 = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9] text-sm">' +
|
||
|
|
(window.getTranslation ? window.getTranslation('dashboard.noData', 'No data available') : 'No data available') + '</p>';
|
||
|
|
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 `
|
||
|
|
<div class="flex items-center gap-1.5 group cursor-pointer hover:opacity-80 transition-opacity py-0.5">
|
||
|
|
<span class="size-2 rounded-full flex-shrink-0" style="background: ${cat.color};"></span>
|
||
|
|
<span class="text-text-muted dark:text-[#92adc9] text-[10px] truncate flex-1 leading-tight">${cat.name}</span>
|
||
|
|
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium">${percent}%</span>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}).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 = `<p class="text-[#92adc9] text-sm text-center py-8">${noTransText}</p>`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
container.innerHTML = data.transactions.map(tx => `
|
||
|
|
<div class="flex items-center justify-between p-4 rounded-lg bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] hover:border-primary/30 transition-colors">
|
||
|
|
<div class="flex items-center gap-3 flex-1">
|
||
|
|
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: ${tx.category_color}20;">
|
||
|
|
<span class="material-symbols-outlined text-[20px]" style="color: ${tx.category_color};">payments</span>
|
||
|
|
</div>
|
||
|
|
<div class="flex-1">
|
||
|
|
<p class="text-text-main dark:text-white font-medium text-sm">${tx.description}</p>
|
||
|
|
<p class="text-text-muted dark:text-[#92adc9] text-xs">${tx.category_name} • ${formatDate(tx.date)}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="text-right">
|
||
|
|
<p class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, window.userCurrency || 'RON')}</p>
|
||
|
|
${tx.tags.length > 0 ? `<p class="text-text-muted dark:text-[#92adc9] text-xs">${tx.tags.join(', ')}</p>` : ''}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`).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 = '<p class="col-span-3 text-center text-text-muted dark:text-[#92adc9] py-8">' +
|
||
|
|
(window.getTranslation ? window.getTranslation('dashboard.noCategories', 'No categories yet') : 'No categories yet') + '</p>';
|
||
|
|
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 = `
|
||
|
|
<div class="mt-3 pt-3 border-t border-border-light dark:border-[#233648]">
|
||
|
|
<div class="flex justify-between items-center mb-2 text-xs">
|
||
|
|
<span class="text-text-muted dark:text-[#92adc9]">${window.getTranslation('budget.budgetAmount', 'Budget')}</span>
|
||
|
|
<span class="font-medium text-text-main dark:text-white">${formatCurrency(budgetStatus.spent, window.userCurrency)} / ${formatCurrency(budgetStatus.budget, window.userCurrency)}</span>
|
||
|
|
</div>
|
||
|
|
<div class="w-full bg-slate-200 dark:bg-[#111a22] rounded-full h-2">
|
||
|
|
<div class="h-2 rounded-full transition-all duration-500" style="width: ${budgetPercentage}%; background: ${budgetColor};"></div>
|
||
|
|
</div>
|
||
|
|
<div class="text-xs text-right mt-1 font-medium" style="color: ${budgetColor}">${budgetStatus.percentage.toFixed(0)}%</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
return `
|
||
|
|
<div class="category-card bg-white dark:bg-[#0f1921] rounded-xl p-5 border border-border-light dark:border-[#233648] hover:border-primary/30 transition-all hover:shadow-lg cursor-pointer touch-manipulation"
|
||
|
|
draggable="true"
|
||
|
|
data-category-id="${cat.id}"
|
||
|
|
onclick="event.stopPropagation(); if (!event.target.closest('button')) { showCategoryExpenses(${cat.id}, '${cat.name.replace(/'/g, "\\'")}', '${cat.color}', '${icon}'); }"
|
||
|
|
title="${window.getTranslation('categories.viewExpenses', 'Click to view expenses')}">
|
||
|
|
<div class="flex items-start justify-between mb-4">
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<div class="w-12 h-12 rounded-xl flex items-center justify-center" style="background: ${cat.color};">
|
||
|
|
<span class="material-symbols-outlined text-white text-[24px]">${getValidIcon(icon)}</span>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<h3 class="font-semibold text-text-main dark:text-white">${cat.name}</h3>
|
||
|
|
<p class="text-xs text-text-muted dark:text-[#92adc9]">${count} ${count === 1 ? (window.getTranslation ? window.getTranslation('transactions.transaction', 'transaction') : 'transaction') : (window.getTranslation ? window.getTranslation('transactions.transactions', 'transactions') : 'transactions')}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<span class="text-xs font-medium text-text-muted dark:text-[#92adc9] bg-slate-100 dark:bg-[#111a22] px-2 py-1 rounded-full">${percentage}%</span>
|
||
|
|
<button onclick="event.stopPropagation(); showCategoryBudgetModal(${cat.id}, '${cat.name.replace(/'/g, "\\'")}', ${cat.monthly_budget || 'null'}, ${cat.budget_alert_threshold || 0.9})"
|
||
|
|
class="p-1 hover:bg-slate-100 dark:hover:bg-[#111a22] rounded-lg transition-colors"
|
||
|
|
title="${window.getTranslation('budget.setBudget', 'Set Budget')}">
|
||
|
|
<span class="material-symbols-outlined text-[18px] text-text-muted dark:text-[#92adc9]">settings</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="mb-2">
|
||
|
|
<p class="text-2xl font-bold text-text-main dark:text-white">${formatCurrency(total, window.userCurrency || 'RON')}</p>
|
||
|
|
</div>
|
||
|
|
<div class="w-full bg-slate-200 dark:bg-[#111a22] rounded-full h-2 overflow-hidden">
|
||
|
|
<div class="h-2 rounded-full transition-all duration-500" style="width: ${percentageCapped}%; background: ${mainProgressColor};"></div>
|
||
|
|
</div>
|
||
|
|
${budgetDisplay}
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}).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 = `<option value="">${selectText}</option>` +
|
||
|
|
data.categories.map(cat => {
|
||
|
|
const translationKey = categoryTranslations[cat.name];
|
||
|
|
const translatedName = translationKey && window.getTranslation
|
||
|
|
? window.getTranslation(translationKey, cat.name)
|
||
|
|
: cat.name;
|
||
|
|
return `<option value="${cat.id}">${translatedName}</option>`;
|
||
|
|
}).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 = `
|
||
|
|
<div class="text-xs text-text-muted dark:text-[#92adc9] mb-1">
|
||
|
|
<span class="material-symbols-outlined text-[14px] align-middle">lightbulb</span>
|
||
|
|
<span data-translate="tags.suggestedTags">Suggested Tags</span>
|
||
|
|
</div>
|
||
|
|
<div id="suggestedTagsList" class="flex flex-wrap gap-2"></div>
|
||
|
|
`;
|
||
|
|
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 = `
|
||
|
|
<span class="material-symbols-outlined" style="font-size: 14px;">${tag.icon}</span>
|
||
|
|
<span>#${tag.name}</span>
|
||
|
|
`;
|
||
|
|
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) => `
|
||
|
|
<div class="category-item bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg p-4 flex items-center justify-between hover:border-primary/30 transition-all cursor-move"
|
||
|
|
draggable="true"
|
||
|
|
data-id="${cat.id}"
|
||
|
|
data-order="${index}">
|
||
|
|
<div class="flex items-center gap-3 flex-1">
|
||
|
|
<span class="material-symbols-outlined text-[24px] drag-handle cursor-move" style="color: ${cat.color};">drag_indicator</span>
|
||
|
|
<button type="button" onclick="openIconPicker('edit-${cat.id}')"
|
||
|
|
class="w-10 h-10 rounded-lg flex items-center justify-center hover:opacity-80 transition-opacity"
|
||
|
|
style="background: ${cat.color};"
|
||
|
|
title="Click to change icon">
|
||
|
|
<span class="material-symbols-outlined text-white text-[20px]">${getValidIcon(cat.icon)}</span>
|
||
|
|
</button>
|
||
|
|
<div class="flex-1">
|
||
|
|
<p class="text-text-main dark:text-white font-medium">${cat.name}</p>
|
||
|
|
<p class="text-text-muted dark:text-[#92adc9] text-xs">${cat.color} • ${CATEGORY_ICONS[getValidIcon(cat.icon)] || 'General'}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<button onclick="deleteCategory(${cat.id})" class="text-red-500 hover:text-red-600 p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
|
||
|
|
<span class="material-symbols-outlined text-[20px]">delete</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`).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 = `
|
||
|
|
<div class="bg-white dark:bg-[#0f1921] rounded-lg shadow-xl max-w-md w-full">
|
||
|
|
<div class="p-6 border-b border-border-light dark:border-[#233648]">
|
||
|
|
<div class="flex justify-between items-center">
|
||
|
|
<h2 class="text-2xl font-bold text-text-main dark:text-white">${categoryName}</h2>
|
||
|
|
<button onclick="document.getElementById('categoryBudgetModal').remove()" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white">
|
||
|
|
<span class="material-symbols-outlined">close</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<p class="text-sm text-text-muted dark:text-[#92adc9] mt-2">${window.getTranslation('budget.editBudget', 'Edit Budget')}</p>
|
||
|
|
</div>
|
||
|
|
<form id="budgetForm" class="p-6 space-y-4">
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium text-text-main dark:text-white mb-2">
|
||
|
|
${window.getTranslation('budget.budgetAmount', 'Budget Amount')}
|
||
|
|
</label>
|
||
|
|
<input type="number"
|
||
|
|
id="budgetAmount"
|
||
|
|
step="0.01"
|
||
|
|
min="0"
|
||
|
|
value="${budgetValue}"
|
||
|
|
placeholder="0.00"
|
||
|
|
class="w-full px-4 py-2 border border-border-light dark:border-[#233648] rounded-lg bg-white dark:bg-[#111a22] text-text-main dark:text-white focus:ring-2 focus:ring-primary/50">
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label class="block text-sm font-medium text-text-main dark:text-white mb-2">
|
||
|
|
${window.getTranslation('budget.alertThreshold', 'Alert Threshold')} (${thresholdPercent}%)
|
||
|
|
</label>
|
||
|
|
<input type="range"
|
||
|
|
id="budgetThreshold"
|
||
|
|
min="50"
|
||
|
|
max="200"
|
||
|
|
value="${thresholdPercent}"
|
||
|
|
oninput="document.getElementById('thresholdValue').textContent = this.value + '%'"
|
||
|
|
class="w-full">
|
||
|
|
<div class="flex justify-between text-xs text-text-muted dark:text-[#92adc9] mt-2">
|
||
|
|
<span>50%</span>
|
||
|
|
<span id="thresholdValue" class="font-semibold text-text-main dark:text-white">${thresholdPercent}%</span>
|
||
|
|
<span>200%</span>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs text-text-muted dark:text-[#92adc9] mt-2">${window.getTranslation('budget.alertThresholdHelp', 'Get notified when spending reaches this percentage')}</p>
|
||
|
|
</div>
|
||
|
|
<div class="flex gap-3 pt-4">
|
||
|
|
<button type="button"
|
||
|
|
onclick="document.getElementById('categoryBudgetModal').remove()"
|
||
|
|
class="flex-1 px-4 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
|
||
|
|
${window.getTranslation('budget.cancel', 'Cancel')}
|
||
|
|
</button>
|
||
|
|
<button type="submit"
|
||
|
|
class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">
|
||
|
|
${window.getTranslation('budget.save', 'Save Budget')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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 =>
|
||
|
|
`<option value="${cat.id}">${cat.name}</option>`
|
||
|
|
).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 = `
|
||
|
|
<div class="bg-card-light dark:bg-card-dark rounded-2xl shadow-2xl max-w-md w-full border border-border-light dark:border-[#233648]">
|
||
|
|
<div class="p-6">
|
||
|
|
<div class="flex items-center gap-3 mb-4">
|
||
|
|
<div class="p-2 bg-red-500/10 rounded-lg">
|
||
|
|
<span class="material-symbols-outlined text-red-500">warning</span>
|
||
|
|
</div>
|
||
|
|
<h3 class="text-lg font-bold text-text-main dark:text-white">Delete Category</h3>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p class="text-text-muted dark:text-[#92adc9] mb-4">
|
||
|
|
This category has ${error.expense_count} expense${error.expense_count > 1 ? 's' : ''}.
|
||
|
|
Where would you like to move ${error.expense_count > 1 ? 'them' : 'it'}?
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div class="mb-4">
|
||
|
|
<label class="block text-sm font-medium text-text-main dark:text-white mb-2">Move expenses to:</label>
|
||
|
|
<select id="move-to-category-select"
|
||
|
|
class="w-full px-3 py-2 bg-white dark:bg-card-dark border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
|
||
|
|
<option value="">Select category...</option>
|
||
|
|
${options}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex gap-3 justify-end">
|
||
|
|
<button id="cancel-delete-btn"
|
||
|
|
class="px-4 py-2 bg-background-light dark:bg-background-dark text-text-main dark:text-white rounded-lg hover:bg-slate-200 dark:hover:bg-[#233648] transition-colors font-medium text-sm">
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button id="confirm-delete-btn"
|
||
|
|
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors font-medium text-sm">
|
||
|
|
Delete
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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]) => `
|
||
|
|
<button type="button"
|
||
|
|
onclick="selectIcon('${icon}')"
|
||
|
|
class="flex flex-col items-center justify-center p-2 rounded-lg border border-border-light dark:border-[#233648] hover:border-primary hover:bg-primary/5 transition-all group min-h-[60px]"
|
||
|
|
title="${label}">
|
||
|
|
<span class="material-symbols-outlined text-text-main dark:text-white">${icon}</span>
|
||
|
|
<span class="text-[8px] text-text-muted dark:text-[#92adc9] text-center leading-tight mt-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap px-1">${label}</span>
|
||
|
|
</button>
|
||
|
|
`).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 `
|
||
|
|
<div class="flex items-center justify-between p-4 rounded-lg bg-slate-50 dark:bg-[#111a22] border border-border-light dark:border-[#233648] hover:border-primary/30 transition-colors">
|
||
|
|
<div class="flex items-center gap-3 flex-1">
|
||
|
|
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: ${categoryColor}20;">
|
||
|
|
<span class="material-symbols-outlined text-[20px]" style="color: ${categoryColor};">${getValidIcon(categoryIcon)}</span>
|
||
|
|
</div>
|
||
|
|
<div class="flex-1">
|
||
|
|
<p class="text-text-main dark:text-white font-medium text-sm">${exp.description}</p>
|
||
|
|
<div class="flex items-center gap-2 mt-1">
|
||
|
|
<p class="text-text-muted dark:text-[#92adc9] text-xs">${formattedDate}</p>
|
||
|
|
${exp.tags && exp.tags.length > 0 ? `
|
||
|
|
<span class="text-text-muted dark:text-[#92adc9] text-xs">•</span>
|
||
|
|
<div class="flex gap-1 flex-wrap">
|
||
|
|
${exp.tags.map(tag => `<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-[10px] rounded">${tag}</span>`).join('')}
|
||
|
|
</div>
|
||
|
|
` : ''}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="text-right ml-3">
|
||
|
|
<p class="text-text-main dark:text-white font-semibold whitespace-nowrap">${formatCurrency(exp.amount, exp.currency || window.userCurrency)}</p>
|
||
|
|
${exp.receipt_path ? `<span class="material-symbols-outlined text-[16px] text-text-muted dark:text-[#92adc9]" title="Has receipt">receipt</span>` : ''}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}).join('');
|
||
|
|
|
||
|
|
list.classList.remove('hidden');
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to load category expenses:', error);
|
||
|
|
loading.classList.add('hidden');
|
||
|
|
list.innerHTML = `
|
||
|
|
<div class="text-center py-12">
|
||
|
|
<span class="material-symbols-outlined text-[48px] text-red-500 mb-3">error</span>
|
||
|
|
<p class="text-text-muted dark:text-[#92adc9]">${window.getTranslation('common.error', 'Failed to load expenses')}</p>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|