Initial commit
This commit is contained in:
commit
983cee0320
322 changed files with 57174 additions and 0 deletions
173
backup/fina-2/app/static/js/admin.js
Normal file
173
backup/fina-2/app/static/js/admin.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// Admin panel functionality
|
||||
let usersData = [];
|
||||
|
||||
// Load users on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.users) {
|
||||
usersData = data.users;
|
||||
updateStats();
|
||||
renderUsersTable();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
showToast(window.getTranslation('admin.errorLoading', 'Error loading users'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const totalUsers = usersData.length;
|
||||
const adminUsers = usersData.filter(u => u.is_admin).length;
|
||||
const twoFAUsers = usersData.filter(u => u.two_factor_enabled).length;
|
||||
|
||||
document.getElementById('total-users').textContent = totalUsers;
|
||||
document.getElementById('admin-users').textContent = adminUsers;
|
||||
document.getElementById('twofa-users').textContent = twoFAUsers;
|
||||
}
|
||||
|
||||
function renderUsersTable() {
|
||||
const tbody = document.getElementById('users-table');
|
||||
|
||||
if (usersData.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-8 text-center text-text-muted dark:text-slate-400">
|
||||
${window.getTranslation('admin.noUsers', 'No users found')}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = usersData.map(user => `
|
||||
<tr class="hover:bg-background-light dark:hover:bg-slate-800/50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-main dark:text-white">${escapeHtml(user.username)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${escapeHtml(user.email)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
${user.is_admin ?
|
||||
`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
|
||||
${window.getTranslation('admin.admin', 'Admin')}
|
||||
</span>` :
|
||||
`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-300">
|
||||
${window.getTranslation('admin.user', 'User')}
|
||||
</span>`
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
${user.two_factor_enabled ?
|
||||
`<span class="material-symbols-outlined text-green-500 text-[20px]">check_circle</span>` :
|
||||
`<span class="material-symbols-outlined text-text-muted dark:text-slate-600 text-[20px]">cancel</span>`
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${user.language.toUpperCase()}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${user.currency}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted dark:text-slate-400">${new Date(user.created_at).toLocaleDateString()}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="editUser(${user.id})" class="text-primary hover:text-primary/80 transition-colors" title="${window.getTranslation('common.edit', 'Edit')}">
|
||||
<span class="material-symbols-outlined text-[20px]">edit</span>
|
||||
</button>
|
||||
<button onclick="deleteUser(${user.id}, '${escapeHtml(user.username)}')" class="text-red-500 hover:text-red-600 transition-colors" title="${window.getTranslation('common.delete', 'Delete')}">
|
||||
<span class="material-symbols-outlined text-[20px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openCreateUserModal() {
|
||||
document.getElementById('create-user-modal').classList.remove('hidden');
|
||||
document.getElementById('create-user-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeCreateUserModal() {
|
||||
document.getElementById('create-user-modal').classList.add('hidden');
|
||||
document.getElementById('create-user-modal').classList.remove('flex');
|
||||
document.getElementById('create-user-form').reset();
|
||||
}
|
||||
|
||||
document.getElementById('create-user-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const userData = {
|
||||
username: formData.get('username'),
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
is_admin: formData.get('is_admin') === 'on'
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(window.getTranslation('admin.userCreated', 'User created successfully'), 'success');
|
||||
closeCreateUserModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
showToast(data.message || window.getTranslation('admin.errorCreating', 'Error creating user'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
showToast(window.getTranslation('admin.errorCreating', 'Error creating user'), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm(window.getTranslation('admin.confirmDelete', 'Are you sure you want to delete user') + ` "${username}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(window.getTranslation('admin.userDeleted', 'User deleted successfully'), 'success');
|
||||
loadUsers();
|
||||
} else {
|
||||
showToast(data.message || window.getTranslation('admin.errorDeleting', 'Error deleting user'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
showToast(window.getTranslation('admin.errorDeleting', 'Error deleting user'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editUser(userId) {
|
||||
// Placeholder for edit functionality
|
||||
showToast(window.getTranslation('admin.editNotImplemented', 'Edit functionality coming soon'), 'info');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(message, type);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
181
backup/fina-2/app/static/js/app.js
Normal file
181
backup/fina-2/app/static/js/app.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Global utility functions
|
||||
|
||||
// Toast notifications
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
info: 'bg-primary',
|
||||
warning: 'bg-yellow-500'
|
||||
};
|
||||
|
||||
toast.className = `${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in`;
|
||||
toast.innerHTML = `
|
||||
<span class="material-symbols-outlined text-[20px]">
|
||||
${type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'}
|
||||
</span>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Format currency
|
||||
function formatCurrency(amount, currency = 'USD') {
|
||||
const symbols = {
|
||||
'USD': '$',
|
||||
'EUR': '€',
|
||||
'GBP': '£',
|
||||
'RON': 'lei'
|
||||
};
|
||||
|
||||
const symbol = symbols[currency] || currency;
|
||||
const formatted = parseFloat(amount).toFixed(2);
|
||||
|
||||
if (currency === 'RON') {
|
||||
return `${formatted} ${symbol}`;
|
||||
}
|
||||
return `${symbol}${formatted}`;
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return window.getTranslation ? window.getTranslation('date.today', 'Today') : 'Today';
|
||||
if (days === 1) return window.getTranslation ? window.getTranslation('date.yesterday', 'Yesterday') : 'Yesterday';
|
||||
if (days < 7) {
|
||||
const daysAgoText = window.getTranslation ? window.getTranslation('date.daysAgo', 'days ago') : 'days ago';
|
||||
return `${days} ${daysAgoText}`;
|
||||
}
|
||||
|
||||
const lang = window.getCurrentLanguage ? window.getCurrentLanguage() : 'en';
|
||||
const locale = lang === 'ro' ? 'ro-RO' : 'en-US';
|
||||
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
// API helper
|
||||
async function apiCall(url, options = {}) {
|
||||
try {
|
||||
// Don't set Content-Type header for FormData - browser will set it automatically with boundary
|
||||
const headers = options.body instanceof FormData
|
||||
? { ...options.headers }
|
||||
: { ...options.headers, 'Content-Type': 'application/json' };
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to get error message from response
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
const errorMsg = errorData.message || window.getTranslation('common.error', 'An error occurred. Please try again.');
|
||||
showToast(errorMsg, 'error');
|
||||
} catch (e) {
|
||||
showToast(window.getTranslation('common.error', 'An error occurred. Please try again.'), 'error');
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
if (!error.message.includes('HTTP error')) {
|
||||
showToast(window.getTranslation('common.error', 'An error occurred. Please try again.'), 'error');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
// Theme is already applied in head, just update UI
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
updateThemeUI(isDark);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
updateThemeUI(false);
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
updateThemeUI(true);
|
||||
}
|
||||
|
||||
// Dispatch custom event for other components to react to theme change
|
||||
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { isDark: !isDark } }));
|
||||
}
|
||||
|
||||
function updateThemeUI(isDark) {
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeText = document.getElementById('theme-text');
|
||||
|
||||
if (themeIcon && themeText) {
|
||||
if (isDark) {
|
||||
themeIcon.textContent = 'dark_mode';
|
||||
const darkModeText = window.getTranslation ? window.getTranslation('dashboard.darkMode', 'Dark Mode') : 'Dark Mode';
|
||||
themeText.textContent = darkModeText;
|
||||
themeText.setAttribute('data-translate', 'dashboard.darkMode');
|
||||
} else {
|
||||
themeIcon.textContent = 'light_mode';
|
||||
const lightModeText = window.getTranslation ? window.getTranslation('dashboard.lightMode', 'Light Mode') : 'Light Mode';
|
||||
themeText.textContent = lightModeText;
|
||||
themeText.setAttribute('data-translate', 'dashboard.lightMode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile menu toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize theme
|
||||
initTheme();
|
||||
|
||||
// Theme toggle button
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile menu
|
||||
const menuToggle = document.getElementById('menu-toggle');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
if (menuToggle && sidebar) {
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('hidden');
|
||||
sidebar.classList.toggle('flex');
|
||||
sidebar.classList.toggle('absolute');
|
||||
sidebar.classList.toggle('z-50');
|
||||
sidebar.style.left = '0';
|
||||
});
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth < 1024) {
|
||||
if (!sidebar.contains(e.target) && !menuToggle.contains(e.target)) {
|
||||
sidebar.classList.add('hidden');
|
||||
sidebar.classList.remove('flex');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
781
backup/fina-2/app/static/js/dashboard.js
Normal file
781
backup/fina-2/app/static/js/dashboard.js
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
// Dashboard JavaScript
|
||||
|
||||
let categoryChart, monthlyChart;
|
||||
|
||||
// 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 || 'RON';
|
||||
|
||||
// Ensure we have valid data with defaults
|
||||
const totalSpent = parseFloat(stats.total_spent || 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);
|
||||
document.getElementById('active-categories').textContent = activeCategories;
|
||||
document.getElementById('total-transactions').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 - slim & elegant for 12 months PWA design
|
||||
function loadMonthlyChart(data) {
|
||||
const ctx = document.getElementById('monthly-chart').getContext('2d');
|
||||
|
||||
if (monthlyChart) {
|
||||
monthlyChart.destroy();
|
||||
}
|
||||
|
||||
monthlyChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.month),
|
||||
datasets: [{
|
||||
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Spending') : 'Spending',
|
||||
data: data.map(d => d.total),
|
||||
backgroundColor: '#2b8cee',
|
||||
borderRadius: 6,
|
||||
barPercentage: 0.5, // Make bars slimmer
|
||||
categoryPercentage: 0.7 // Tighter spacing between bars
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
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: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const userCurrency = window.userCurrency || 'RON';
|
||||
return 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, // Show all 12 months
|
||||
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;
|
||||
}
|
||||
|
||||
// Icon mapping
|
||||
const categoryIcons = {
|
||||
'Food & Dining': 'restaurant',
|
||||
'Transportation': 'directions_car',
|
||||
'Shopping': 'shopping_cart',
|
||||
'Entertainment': 'movie',
|
||||
'Bills & Utilities': 'receipt',
|
||||
'Healthcare': 'medical_services',
|
||||
'Education': 'school',
|
||||
'Other': 'category'
|
||||
};
|
||||
|
||||
// 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 icon = categoryIcons[cat.name] || 'category';
|
||||
|
||||
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-move touch-manipulation"
|
||||
draggable="true"
|
||||
data-category-id="${cat.id}">
|
||||
<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]">${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>
|
||||
<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>
|
||||
</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">
|
||||
<div class="h-2 rounded-full transition-all duration-500" style="width: ${percentage}%; background: ${cat.color};"></div>
|
||||
</div>
|
||||
</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();
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
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">
|
||||
<span class="material-symbols-outlined text-[24px] drag-handle cursor-move" style="color: ${cat.color};">drag_indicator</span>
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: ${cat.color};">
|
||||
<span class="material-symbols-outlined text-white text-[20px]">${cat.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<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} • ${cat.icon}</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');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete category
|
||||
async function deleteCategory(id) {
|
||||
if (!confirm(getTranslation('common.delete', 'Are you sure?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiCall(`/api/expenses/categories/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showToast(getTranslation('categories.deleted', 'Category deleted successfully'), 'success');
|
||||
await loadCategoriesManagement();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete category:', error);
|
||||
if (error.message && error.message.includes('expenses')) {
|
||||
showToast(getTranslation('categories.hasExpenses', 'Cannot delete category with expenses'), 'error');
|
||||
} else {
|
||||
showToast(getTranslation('common.error', 'An error occurred'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make deleteCategory global
|
||||
window.deleteCategory = deleteCategory;
|
||||
|
||||
// Initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDashboardData();
|
||||
|
||||
// Refresh data every 5 minutes
|
||||
setInterval(loadDashboardData, 5 * 60 * 1000);
|
||||
});
|
||||
485
backup/fina-2/app/static/js/documents.js
Normal file
485
backup/fina-2/app/static/js/documents.js
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
// Documents Page Functionality
|
||||
let currentPage = 1;
|
||||
const itemsPerPage = 10;
|
||||
let searchQuery = '';
|
||||
let allDocuments = [];
|
||||
|
||||
// Initialize documents page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDocuments();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
// File input change
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
if (uploadArea) {
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('!border-primary', '!bg-primary/5');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('!border-primary', '!bg-primary/5');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('!border-primary', '!bg-primary/5');
|
||||
const files = e.dataTransfer.files;
|
||||
handleFiles(files);
|
||||
});
|
||||
}
|
||||
|
||||
// Search input
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
let debounceTimer;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchQuery = e.target.value.toLowerCase();
|
||||
currentPage = 1;
|
||||
loadDocuments();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file select from input
|
||||
function handleFileSelect(e) {
|
||||
const files = e.target.files;
|
||||
handleFiles(files);
|
||||
}
|
||||
|
||||
// Handle file upload
|
||||
async function handleFiles(files) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const allowedTypes = ['pdf', 'csv', 'xlsx', 'xls', 'png', 'jpg', 'jpeg'];
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
for (const file of files) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(ext)) {
|
||||
showNotification('error', `${file.name}: Unsupported file type. Only PDF, CSV, XLS, XLSX, PNG, JPG allowed.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
showNotification('error', `${file.name}: File size exceeds 10MB limit.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await uploadFile(file);
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
// Reload documents list
|
||||
loadDocuments();
|
||||
}
|
||||
|
||||
// Upload file to server
|
||||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/documents/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showNotification('success', `${file.name} uploaded successfully!`);
|
||||
} else {
|
||||
showNotification('error', result.error || 'Upload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
showNotification('error', 'An error occurred during upload');
|
||||
}
|
||||
}
|
||||
|
||||
// Load documents from API
|
||||
async function loadDocuments() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage
|
||||
});
|
||||
|
||||
if (searchQuery) {
|
||||
params.append('search', searchQuery);
|
||||
}
|
||||
|
||||
const data = await apiCall(`/api/documents/?${params.toString()}`);
|
||||
|
||||
allDocuments = data.documents;
|
||||
displayDocuments(data.documents);
|
||||
updatePagination(data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Error loading documents:', error);
|
||||
document.getElementById('documents-list').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-text-muted dark:text-[#92adc9]">
|
||||
<span data-translate="documents.errorLoading">Failed to load documents. Please try again.</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display documents in table
|
||||
function displayDocuments(documents) {
|
||||
const tbody = document.getElementById('documents-list');
|
||||
|
||||
if (documents.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-text-muted dark:text-[#92adc9]">
|
||||
<span data-translate="documents.noDocuments">No documents found. Upload your first document!</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = documents.map(doc => {
|
||||
const statusConfig = getStatusConfig(doc.status);
|
||||
const fileIcon = getFileIcon(doc.file_type);
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-white/[0.02] transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-[20px] ${fileIcon.color}">${fileIcon.icon}</span>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-text-main dark:text-white font-medium">${escapeHtml(doc.original_filename)}</span>
|
||||
<span class="text-xs text-text-muted dark:text-[#92adc9]">${formatFileSize(doc.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-text-main dark:text-white">
|
||||
${formatDate(doc.created_at)}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 dark:bg-white/10 text-text-main dark:text-white">
|
||||
${doc.document_category || 'Other'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig.className}">
|
||||
${statusConfig.hasIcon ? `<span class="material-symbols-outlined text-[14px]">${statusConfig.icon}</span>` : ''}
|
||||
<span data-translate="documents.status${doc.status.charAt(0).toUpperCase() + doc.status.slice(1)}">${doc.status}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
${['PNG', 'JPG', 'JPEG', 'PDF'].includes(doc.file_type.toUpperCase()) ?
|
||||
`<button onclick="viewDocument(${doc.id}, '${doc.file_type}', '${escapeHtml(doc.original_filename)}')" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-primary hover:bg-primary/10 rounded-lg transition-colors" title="View">
|
||||
<span class="material-symbols-outlined text-[20px]">visibility</span>
|
||||
</button>` : ''
|
||||
}
|
||||
<button onclick="downloadDocument(${doc.id})" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-primary hover:bg-primary/10 rounded-lg transition-colors" title="Download">
|
||||
<span class="material-symbols-outlined text-[20px]">download</span>
|
||||
</button>
|
||||
<button onclick="deleteDocument(${doc.id})" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors" title="Delete">
|
||||
<span class="material-symbols-outlined text-[20px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Get status configuration
|
||||
function getStatusConfig(status) {
|
||||
const configs = {
|
||||
uploaded: {
|
||||
className: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
|
||||
icon: 'upload',
|
||||
hasIcon: true
|
||||
},
|
||||
processing: {
|
||||
className: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400 animate-pulse',
|
||||
icon: 'sync',
|
||||
hasIcon: true
|
||||
},
|
||||
analyzed: {
|
||||
className: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
icon: 'verified',
|
||||
hasIcon: true
|
||||
},
|
||||
error: {
|
||||
className: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400',
|
||||
icon: 'error',
|
||||
hasIcon: true
|
||||
}
|
||||
};
|
||||
|
||||
return configs[status] || configs.uploaded;
|
||||
}
|
||||
|
||||
// Get file icon
|
||||
function getFileIcon(fileType) {
|
||||
const icons = {
|
||||
pdf: { icon: 'picture_as_pdf', color: 'text-red-500' },
|
||||
csv: { icon: 'table_view', color: 'text-green-500' },
|
||||
xlsx: { icon: 'table_view', color: 'text-green-600' },
|
||||
xls: { icon: 'table_view', color: 'text-green-600' },
|
||||
png: { icon: 'image', color: 'text-blue-500' },
|
||||
jpg: { icon: 'image', color: 'text-blue-500' },
|
||||
jpeg: { icon: 'image', color: 'text-blue-500' }
|
||||
};
|
||||
|
||||
return icons[fileType?.toLowerCase()] || { icon: 'description', color: 'text-gray-500' };
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
function updatePagination(pagination) {
|
||||
const { page, pages, total, per_page } = pagination;
|
||||
|
||||
// Update count display
|
||||
const start = (page - 1) * per_page + 1;
|
||||
const end = Math.min(page * per_page, total);
|
||||
|
||||
document.getElementById('page-start').textContent = total > 0 ? start : 0;
|
||||
document.getElementById('page-end').textContent = end;
|
||||
document.getElementById('total-count').textContent = total;
|
||||
|
||||
// Update pagination buttons
|
||||
const paginationDiv = document.getElementById('pagination');
|
||||
|
||||
if (pages <= 1) {
|
||||
paginationDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let buttons = '';
|
||||
|
||||
// Previous button
|
||||
buttons += `
|
||||
<button onclick="changePage(${page - 1})"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium ${page === 1 ? 'bg-gray-100 dark:bg-white/5 text-text-muted dark:text-[#92adc9]/50 cursor-not-allowed' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors"
|
||||
${page === 1 ? 'disabled' : ''}>
|
||||
<span class="material-symbols-outlined text-[18px]">chevron_left</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Page numbers
|
||||
const maxButtons = 5;
|
||||
let startPage = Math.max(1, page - Math.floor(maxButtons / 2));
|
||||
let endPage = Math.min(pages, startPage + maxButtons - 1);
|
||||
|
||||
if (endPage - startPage < maxButtons - 1) {
|
||||
startPage = Math.max(1, endPage - maxButtons + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
buttons += `
|
||||
<button onclick="changePage(${i})"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium ${i === page ? 'bg-primary text-white' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors">
|
||||
${i}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons += `
|
||||
<button onclick="changePage(${page + 1})"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium ${page === pages ? 'bg-gray-100 dark:bg-white/5 text-text-muted dark:text-[#92adc9]/50 cursor-not-allowed' : 'bg-card-light dark:bg-card-dark text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-white/10 border border-border-light dark:border-[#233648]'} transition-colors"
|
||||
${page === pages ? 'disabled' : ''}>
|
||||
<span class="material-symbols-outlined text-[18px]">chevron_right</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
paginationDiv.innerHTML = buttons;
|
||||
}
|
||||
|
||||
// Change page
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadDocuments();
|
||||
}
|
||||
|
||||
// View document (preview in modal)
|
||||
function viewDocument(id, fileType, filename) {
|
||||
const modalHtml = `
|
||||
<div id="document-preview-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4" onclick="closePreviewModal(event)">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl max-w-5xl w-full max-h-[90vh] overflow-hidden shadow-2xl" onclick="event.stopPropagation()">
|
||||
<div class="flex items-center justify-between p-4 border-b border-border-light dark:border-[#233648]">
|
||||
<h3 class="text-lg font-semibold text-text-main dark:text-white truncate">${escapeHtml(filename)}</h3>
|
||||
<button onclick="closePreviewModal()" class="p-2 text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white rounded-lg transition-colors">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 overflow-auto max-h-[calc(90vh-80px)]">
|
||||
${fileType.toUpperCase() === 'PDF'
|
||||
? `<iframe src="/api/documents/${id}/view" class="w-full h-[70vh] border-0 rounded-lg"></iframe>`
|
||||
: `<img src="/api/documents/${id}/view" alt="${escapeHtml(filename)}" class="max-w-full h-auto mx-auto rounded-lg">`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
}
|
||||
|
||||
// Close preview modal
|
||||
function closePreviewModal(event) {
|
||||
if (!event || event.target.id === 'document-preview-modal' || !event.target.closest) {
|
||||
const modal = document.getElementById('document-preview-modal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download document
|
||||
async function downloadDocument(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}/download`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filename = contentDisposition
|
||||
? contentDisposition.split('filename=')[1].replace(/"/g, '')
|
||||
: `document_${id}`;
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showNotification('success', 'Document downloaded successfully!');
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
showNotification('error', 'Failed to download document');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete document
|
||||
async function deleteDocument(id) {
|
||||
const confirmMsg = getCurrentLanguage() === 'ro'
|
||||
? 'Ești sigur că vrei să ștergi acest document? Această acțiune nu poate fi anulată.'
|
||||
: 'Are you sure you want to delete this document? This action cannot be undone.';
|
||||
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showNotification('success', 'Document deleted successfully!');
|
||||
loadDocuments();
|
||||
} else {
|
||||
showNotification('error', result.error || 'Failed to delete document');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
showNotification('error', 'An error occurred while deleting');
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return minutes <= 1 ? 'Just now' : `${minutes}m ago`;
|
||||
}
|
||||
return `${hours}h ago`;
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(type, message) {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slideIn ${
|
||||
type === 'success'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
}`;
|
||||
|
||||
notification.innerHTML = `
|
||||
<span class="material-symbols-outlined text-[20px]">
|
||||
${type === 'success' ? 'check_circle' : 'error'}
|
||||
</span>
|
||||
<span class="text-sm font-medium">${escapeHtml(message)}</span>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
notification.classList.add('animate-slideOut');
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
642
backup/fina-2/app/static/js/i18n.js
Normal file
642
backup/fina-2/app/static/js/i18n.js
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
// Multi-language support
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.transactions': 'Transactions',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.logout': 'Log out',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.total_spent': 'Total Spent This Month',
|
||||
'dashboard.active_categories': 'Active Categories',
|
||||
'dashboard.total_transactions': 'Total Transactions',
|
||||
'dashboard.vs_last_month': 'vs last month',
|
||||
'dashboard.categories_in_use': 'categories in use',
|
||||
'dashboard.this_month': 'current month',
|
||||
'dashboard.spending_by_category': 'Spending by Category',
|
||||
'dashboard.monthly_trend': 'Monthly Trend',
|
||||
'dashboard.recent_transactions': 'Recent Transactions',
|
||||
'dashboard.view_all': 'View All',
|
||||
'dashboard.search': 'Search expenses...',
|
||||
'dashboard.selectCategory': 'Select category...',
|
||||
'dashboard.noTransactions': 'No transactions yet',
|
||||
'dashboard.noData': 'No data available',
|
||||
'dashboard.total': 'Total',
|
||||
'dashboard.totalThisYear': 'Total This Year',
|
||||
'dashboard.spending': 'Spending',
|
||||
'dashboard.categoryBreakdownDesc': 'Breakdown by category',
|
||||
'dashboard.lightMode': 'Light Mode',
|
||||
'dashboard.darkMode': 'Dark Mode',
|
||||
'dashboard.expenseAdded': 'Expense added successfully!',
|
||||
|
||||
// Login
|
||||
'login.title': 'Welcome Back',
|
||||
'login.tagline': 'Track your expenses, manage your finances',
|
||||
'login.remember_me': 'Remember me',
|
||||
'login.sign_in': 'Sign In',
|
||||
'login.no_account': "Don't have an account?",
|
||||
'login.register': 'Register',
|
||||
|
||||
// Register
|
||||
'register.title': 'Create Account',
|
||||
'register.tagline': 'Start managing your finances today',
|
||||
'register.create_account': 'Create Account',
|
||||
'register.have_account': 'Already have an account?',
|
||||
'register.login': 'Login',
|
||||
|
||||
// Forms
|
||||
'form.email': 'Email',
|
||||
'form.password': 'Password',
|
||||
'form.username': 'Username',
|
||||
'form.language': 'Language',
|
||||
'form.currency': 'Currency',
|
||||
'form.monthlyBudget': 'Monthly Budget',
|
||||
'form.amount': 'Amount',
|
||||
'form.description': 'Description',
|
||||
'form.category': 'Category',
|
||||
'form.date': 'Date',
|
||||
'form.tags': 'Tags (comma separated)',
|
||||
'form.receipt': 'Receipt (optional)',
|
||||
'form.2fa_code': '2FA Code',
|
||||
'form.chooseFile': 'Choose File',
|
||||
'form.noFileChosen': 'No file chosen',
|
||||
|
||||
// Transactions
|
||||
'transactions.title': 'Transactions',
|
||||
'transactions.export': 'Export CSV',
|
||||
'transactions.import': 'Import CSV',
|
||||
'transactions.addExpense': 'Add Expense',
|
||||
'transactions.search': 'Search transactions...',
|
||||
'transactions.date': 'Date',
|
||||
'transactions.filters': 'Filters',
|
||||
'transactions.category': 'Category',
|
||||
'transactions.allCategories': 'Category',
|
||||
'transactions.startDate': 'Start Date',
|
||||
'transactions.endDate': 'End Date',
|
||||
'transactions.tableTransaction': 'Transaction',
|
||||
'transactions.tableCategory': 'Category',
|
||||
'transactions.tableDate': 'Date',
|
||||
'transactions.tablePayment': 'Payment',
|
||||
'transactions.tableAmount': 'Amount',
|
||||
'transactions.tableStatus': 'Status',
|
||||
'transactions.tableActions': 'Actions',
|
||||
'transactions.showing': 'Showing',
|
||||
'transactions.to': 'to',
|
||||
'transactions.of': 'of',
|
||||
'transactions.results': 'results',
|
||||
'transactions.previous': 'Previous',
|
||||
'transactions.next': 'Next',
|
||||
'transactions.noTransactions': 'No transactions found',
|
||||
'transactions.expense': 'Expense',
|
||||
'transactions.completed': 'Completed',
|
||||
'transactions.pending': 'Pending',
|
||||
'transactions.edit': 'Edit',
|
||||
'transactions.delete': 'Delete',
|
||||
'transactions.updated': 'Transaction updated successfully!',
|
||||
'transactions.notFound': 'Transaction not found',
|
||||
'modal.edit_expense': 'Edit Expense',
|
||||
'actions.update': 'Update Expense',
|
||||
'form.currentReceipt': 'Current receipt attached',
|
||||
'form.receiptHelp': 'Upload a new file to replace existing receipt',
|
||||
'transactions.viewReceipt': 'View Receipt',
|
||||
'transactions.downloadReceipt': 'Download Receipt',
|
||||
'transactions.transaction': 'transaction',
|
||||
'transactions.transactions': 'transactions',
|
||||
'transactions.deleteConfirm': 'Are you sure you want to delete this transaction?',
|
||||
'transactions.deleted': 'Transaction deleted',
|
||||
'transactions.imported': 'Imported',
|
||||
'transactions.importSuccess': 'transactions',
|
||||
|
||||
// Actions
|
||||
'actions.add_expense': 'Add Expense',
|
||||
'actions.save': 'Save Expense',
|
||||
|
||||
// Modal
|
||||
'modal.add_expense': 'Add Expense',
|
||||
|
||||
// Reports
|
||||
'reports.title': 'Financial Reports',
|
||||
'reports.export': 'Export CSV',
|
||||
'reports.analysisPeriod': 'Analysis Period:',
|
||||
'reports.last30Days': 'Last 30 Days',
|
||||
'reports.quarter': 'Quarter',
|
||||
'reports.ytd': 'YTD',
|
||||
'reports.allCategories': 'All Categories',
|
||||
'reports.generate': 'Generate Report',
|
||||
'reports.totalSpent': 'Total Spent',
|
||||
'reports.topCategory': 'Top Category',
|
||||
'reports.avgDaily': 'Avg. Daily',
|
||||
'reports.savingsRate': 'Savings Rate',
|
||||
'reports.vsLastMonth': 'vs last period',
|
||||
'reports.spentThisPeriod': 'spent this period',
|
||||
'reports.placeholder': 'Placeholder',
|
||||
'reports.spendingTrend': 'Spending Trend',
|
||||
'reports.categoryBreakdown': 'Category Breakdown',
|
||||
'reports.monthlySpending': 'Monthly Spending',
|
||||
'reports.smartRecommendations': 'Smart Recommendations',
|
||||
'reports.noRecommendations': 'No recommendations at this time',
|
||||
|
||||
// User
|
||||
'user.admin': 'Admin',
|
||||
'user.user': 'User',
|
||||
|
||||
// Documents
|
||||
'nav.documents': 'Documents',
|
||||
'documents.title': 'Documents',
|
||||
'documents.uploadTitle': 'Upload Documents',
|
||||
'documents.dragDrop': 'Drag & drop files here or click to browse',
|
||||
'documents.uploadDesc': 'Upload bank statements, invoices, or receipts.',
|
||||
'documents.supportedFormats': 'Supported formats: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)',
|
||||
'documents.yourFiles': 'Your Files',
|
||||
'documents.searchPlaceholder': 'Search by name...',
|
||||
'documents.tableDocName': 'Document Name',
|
||||
'documents.tableUploadDate': 'Upload Date',
|
||||
'documents.tableType': 'Type',
|
||||
'documents.tableStatus': 'Status',
|
||||
'documents.tableActions': 'Actions',
|
||||
'documents.statusUploaded': 'Uploaded',
|
||||
'documents.statusProcessing': 'Processing',
|
||||
'documents.statusAnalyzed': 'Analyzed',
|
||||
'documents.statusError': 'Error',
|
||||
'documents.showing': 'Showing',
|
||||
'documents.of': 'of',
|
||||
'documents.documents': 'documents',
|
||||
'documents.noDocuments': 'No documents found. Upload your first document!',
|
||||
'documents.errorLoading': 'Failed to load documents. Please try again.',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'Settings',
|
||||
'settings.avatar': 'Profile Avatar',
|
||||
'settings.uploadAvatar': 'Upload Custom',
|
||||
'settings.avatarDesc': 'PNG, JPG, GIF, WEBP. Max 20MB',
|
||||
'settings.defaultAvatars': 'Or choose a default avatar:',
|
||||
'settings.profile': 'Profile Information',
|
||||
'settings.saveProfile': 'Save Profile',
|
||||
'settings.changePassword': 'Change Password',
|
||||
'settings.currentPassword': 'Current Password',
|
||||
'settings.newPassword': 'New Password',
|
||||
'settings.confirmPassword': 'Confirm New Password',
|
||||
'settings.updatePassword': 'Update Password',
|
||||
'settings.twoFactor': 'Two-Factor Authentication',
|
||||
'settings.twoFactorEnabled': '2FA is currently enabled for your account',
|
||||
'settings.twoFactorDisabled': 'Add an extra layer of security to your account',
|
||||
'settings.enabled': 'Enabled',
|
||||
'settings.disabled': 'Disabled',
|
||||
'settings.regenerateCodes': 'Regenerate Backup Codes',
|
||||
'settings.enable2FA': 'Enable 2FA',
|
||||
'settings.disable2FA': 'Disable 2FA',
|
||||
|
||||
// Two-Factor Authentication
|
||||
'twofa.setupTitle': 'Setup Two-Factor Authentication',
|
||||
'twofa.setupDesc': 'Scan the QR code with your authenticator app',
|
||||
'twofa.step1': 'Step 1: Scan QR Code',
|
||||
'twofa.step1Desc': 'Open your authenticator app (Google Authenticator, Authy, etc.) and scan this QR code:',
|
||||
'twofa.manualEntry': "Can't scan? Enter code manually",
|
||||
'twofa.enterManually': 'Enter this code in your authenticator app:',
|
||||
'twofa.step2': 'Step 2: Verify Code',
|
||||
'twofa.step2Desc': 'Enter the 6-digit code from your authenticator app:',
|
||||
'twofa.enable': 'Enable 2FA',
|
||||
'twofa.infoText': "After enabling 2FA, you'll receive backup codes that you can use if you lose access to your authenticator app. Keep them in a safe place!",
|
||||
'twofa.setupSuccess': 'Two-Factor Authentication Enabled!',
|
||||
'twofa.backupCodesDesc': 'Save these backup codes in a secure location',
|
||||
'twofa.important': 'Important!',
|
||||
'twofa.backupCodesWarning': "Each backup code can only be used once. Store them securely - you'll need them if you lose access to your authenticator app.",
|
||||
'twofa.yourBackupCodes': 'Your Backup Codes',
|
||||
'twofa.downloadPDF': 'Download as PDF',
|
||||
'twofa.print': 'Print Codes',
|
||||
'twofa.continueToSettings': 'Continue to Settings',
|
||||
'twofa.howToUse': 'How to use backup codes:',
|
||||
'twofa.useWhen': "Use a backup code when you can't access your authenticator app",
|
||||
'twofa.enterCode': 'Enter the code in the 2FA field when logging in',
|
||||
'twofa.oneTimeUse': 'Each code works only once - it will be deleted after use',
|
||||
'twofa.regenerate': 'You can regenerate codes anytime from Settings',
|
||||
|
||||
// Admin
|
||||
'admin.title': 'Admin Panel',
|
||||
'admin.subtitle': 'Manage users and system settings',
|
||||
'admin.totalUsers': 'Total Users',
|
||||
'admin.adminUsers': 'Admin Users',
|
||||
'admin.twoFAEnabled': '2FA Enabled',
|
||||
'admin.users': 'Users',
|
||||
'admin.createUser': 'Create User',
|
||||
'admin.username': 'Username',
|
||||
'admin.email': 'Email',
|
||||
'admin.role': 'Role',
|
||||
'admin.twoFA': '2FA',
|
||||
'admin.language': 'Language',
|
||||
'admin.currency': 'Currency',
|
||||
'admin.joined': 'Joined',
|
||||
'admin.actions': 'Actions',
|
||||
'admin.admin': 'Admin',
|
||||
'admin.user': 'User',
|
||||
'admin.createNewUser': 'Create New User',
|
||||
'admin.makeAdmin': 'Make admin',
|
||||
'admin.create': 'Create',
|
||||
'admin.noUsers': 'No users found',
|
||||
'admin.errorLoading': 'Error loading users',
|
||||
'admin.userCreated': 'User created successfully',
|
||||
'admin.errorCreating': 'Error creating user',
|
||||
'admin.confirmDelete': 'Are you sure you want to delete user',
|
||||
'admin.userDeleted': 'User deleted successfully',
|
||||
'admin.errorDeleting': 'Error deleting user',
|
||||
'admin.editNotImplemented': 'Edit functionality coming soon',
|
||||
|
||||
// Categories
|
||||
'categories.foodDining': 'Food & Dining',
|
||||
'categories.transportation': 'Transportation',
|
||||
'categories.shopping': 'Shopping',
|
||||
'categories.entertainment': 'Entertainment',
|
||||
'categories.billsUtilities': 'Bills & Utilities',
|
||||
'categories.healthcare': 'Healthcare',
|
||||
'categories.education': 'Education',
|
||||
'categories.other': 'Other',
|
||||
'categories.manageTitle': 'Manage Categories',
|
||||
'categories.addNew': 'Add New Category',
|
||||
'categories.add': 'Add',
|
||||
'categories.yourCategories': 'Your Categories',
|
||||
'categories.dragToReorder': 'Drag to reorder',
|
||||
'categories.created': 'Category created successfully',
|
||||
'categories.updated': 'Category updated successfully',
|
||||
'categories.deleted': 'Category deleted successfully',
|
||||
'categories.hasExpenses': 'Cannot delete category with expenses',
|
||||
'categories.reordered': 'Categories reordered successfully',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.expenseCategories': 'Expense Categories',
|
||||
'dashboard.manageCategories': 'Manage',
|
||||
|
||||
// Date formatting
|
||||
'date.today': 'Today',
|
||||
'date.yesterday': 'Yesterday',
|
||||
'date.daysAgo': 'days ago',
|
||||
|
||||
// Form
|
||||
'form.name': 'Name',
|
||||
'form.color': 'Color',
|
||||
'form.icon': 'Icon',
|
||||
|
||||
// Common
|
||||
'common.cancel': 'Cancel',
|
||||
'common.edit': 'Edit',
|
||||
'common.delete': 'Delete',
|
||||
'common.error': 'An error occurred. Please try again.',
|
||||
'common.success': 'Operation completed successfully!',
|
||||
'common.missingFields': 'Missing required fields',
|
||||
'common.invalidCategory': 'Invalid category',
|
||||
|
||||
// Actions
|
||||
'actions.cancel': 'Cancel'
|
||||
},
|
||||
ro: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Tablou de bord',
|
||||
'nav.transactions': 'Tranzacții',
|
||||
'nav.reports': 'Rapoarte',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.settings': 'Setări',
|
||||
'nav.logout': 'Deconectare',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.total_spent': 'Total Cheltuit Luna Aceasta',
|
||||
'dashboard.active_categories': 'Categorii Active',
|
||||
'dashboard.total_transactions': 'Total Tranzacții',
|
||||
'dashboard.vs_last_month': 'față de luna trecută',
|
||||
'dashboard.categories_in_use': 'categorii în uz',
|
||||
'dashboard.this_month': 'luna curentă',
|
||||
'dashboard.spending_by_category': 'Cheltuieli pe Categorii',
|
||||
'dashboard.monthly_trend': 'Tendință Lunară',
|
||||
'dashboard.recent_transactions': 'Tranzacții Recente',
|
||||
'dashboard.view_all': 'Vezi Toate',
|
||||
'dashboard.search': 'Caută cheltuieli...',
|
||||
'dashboard.selectCategory': 'Selectează categoria...',
|
||||
'dashboard.noTransactions': 'Nicio tranzacție încă',
|
||||
'dashboard.noData': 'Nu există date disponibile',
|
||||
'dashboard.total': 'Total',
|
||||
'dashboard.totalThisYear': 'Total Anul Acesta',
|
||||
'dashboard.spending': 'Cheltuieli',
|
||||
'dashboard.categoryBreakdownDesc': 'Defalcare pe categorii',
|
||||
'dashboard.lightMode': 'Mod Luminos',
|
||||
'dashboard.darkMode': 'Mod Întunecat',
|
||||
'dashboard.expenseAdded': 'Cheltuială adăugată cu succes!',
|
||||
|
||||
// Login
|
||||
'login.title': 'Bine ai revenit',
|
||||
'login.tagline': 'Urmărește-ți cheltuielile, gestionează-ți finanțele',
|
||||
'login.remember_me': 'Ține-mă minte',
|
||||
'login.sign_in': 'Conectare',
|
||||
'login.no_account': 'Nu ai un cont?',
|
||||
'login.register': 'Înregistrare',
|
||||
|
||||
// Register
|
||||
'register.title': 'Creare Cont',
|
||||
'register.tagline': 'Începe să îți gestionezi finanțele astăzi',
|
||||
'register.create_account': 'Creează Cont',
|
||||
'register.have_account': 'Ai deja un cont?',
|
||||
'register.login': 'Conectare',
|
||||
|
||||
// Forms
|
||||
'form.email': 'Email',
|
||||
'form.password': 'Parolă',
|
||||
'form.username': 'Nume utilizator',
|
||||
'form.language': 'Limbă',
|
||||
'form.currency': 'Monedă',
|
||||
'form.monthlyBudget': 'Buget Lunar',
|
||||
'form.amount': 'Sumă',
|
||||
'form.description': 'Descriere',
|
||||
'form.category': 'Categorie',
|
||||
'form.date': 'Dată',
|
||||
'form.tags': 'Etichete (separate prin virgulă)',
|
||||
'form.receipt': 'Chitanță (opțional)',
|
||||
'form.2fa_code': 'Cod 2FA',
|
||||
'form.chooseFile': 'Alege Fișier',
|
||||
'form.noFileChosen': 'Niciun fișier ales',
|
||||
|
||||
// Transactions
|
||||
'transactions.title': 'Tranzacții',
|
||||
'transactions.export': 'Exportă CSV',
|
||||
'transactions.import': 'Importă CSV',
|
||||
'transactions.addExpense': 'Adaugă Cheltuială',
|
||||
'transactions.search': 'Caută tranzacții...',
|
||||
'transactions.date': 'Dată',
|
||||
'transactions.filters': 'Filtre',
|
||||
'transactions.category': 'Categorie',
|
||||
'transactions.allCategories': 'Categorie',
|
||||
'transactions.startDate': 'Data Început',
|
||||
'transactions.endDate': 'Data Sfârșit',
|
||||
'transactions.tableTransaction': 'Tranzacție',
|
||||
'transactions.tableCategory': 'Categorie',
|
||||
'transactions.tableDate': 'Dată',
|
||||
'transactions.tablePayment': 'Plată',
|
||||
'transactions.tableAmount': 'Sumă',
|
||||
'transactions.tableStatus': 'Stare',
|
||||
'transactions.tableActions': 'Acțiuni',
|
||||
'transactions.showing': 'Afișare',
|
||||
'transactions.to': 'până la',
|
||||
'transactions.of': 'din',
|
||||
'transactions.results': 'rezultate',
|
||||
'transactions.previous': 'Anterior',
|
||||
'transactions.next': 'Următorul',
|
||||
'transactions.noTransactions': 'Nu s-au găsit tranzacții',
|
||||
'transactions.expense': 'Cheltuială',
|
||||
'transactions.completed': 'Finalizat',
|
||||
'transactions.pending': 'În așteptare',
|
||||
'transactions.edit': 'Editează',
|
||||
'transactions.delete': 'Șterge',
|
||||
'transactions.updated': 'Tranzacție actualizată cu succes!',
|
||||
'transactions.notFound': 'Tranzacție negăsită',
|
||||
'modal.edit_expense': 'Editează Cheltuială',
|
||||
'actions.update': 'Actualizează Cheltuială',
|
||||
'form.currentReceipt': 'Chitanță curentă atașată',
|
||||
'form.receiptHelp': 'Încarcă un fișier nou pentru a înlocui chitanța existentă',
|
||||
'transactions.viewReceipt': 'Vezi Chitanța',
|
||||
'transactions.downloadReceipt': 'Descarcă Chitanța',
|
||||
'transactions.transaction': 'tranzacție',
|
||||
'transactions.transactions': 'tranzacții',
|
||||
'transactions.deleteConfirm': 'Ești sigur că vrei să ștergi această tranzacție?',
|
||||
'transactions.deleted': 'Tranzacție ștearsă',
|
||||
'transactions.imported': 'Importate',
|
||||
'transactions.importSuccess': 'tranzacții',
|
||||
|
||||
// Actions
|
||||
'actions.add_expense': 'Adaugă Cheltuială',
|
||||
'actions.save': 'Salvează Cheltuiala',
|
||||
|
||||
// Modal
|
||||
'modal.add_expense': 'Adaugă Cheltuială',
|
||||
|
||||
// Reports
|
||||
'reports.title': 'Rapoarte Financiare',
|
||||
'reports.export': 'Exportă CSV',
|
||||
'reports.analysisPeriod': 'Perioadă de Analiză:',
|
||||
'reports.last30Days': 'Ultimele 30 Zile',
|
||||
'reports.quarter': 'Trimestru',
|
||||
'reports.ytd': 'An Curent',
|
||||
'reports.allCategories': 'Toate Categoriile',
|
||||
'reports.generate': 'Generează Raport',
|
||||
'reports.totalSpent': 'Total Cheltuit',
|
||||
'reports.topCategory': 'Categorie Principală',
|
||||
'reports.avgDaily': 'Medie Zilnică',
|
||||
'reports.savingsRate': 'Rată Economii',
|
||||
'reports.vsLastMonth': 'față de perioada anterioară',
|
||||
'reports.spentThisPeriod': 'cheltuit în această perioadă',
|
||||
'reports.placeholder': 'Substituent',
|
||||
'reports.spendingTrend': 'Tendință Cheltuieli',
|
||||
'reports.categoryBreakdown': 'Defalcare pe Categorii',
|
||||
'reports.monthlySpending': 'Cheltuieli Lunare',
|
||||
'reports.smartRecommendations': 'Recomandări Inteligente',
|
||||
'reports.noRecommendations': 'Nicio recomandare momentan',
|
||||
|
||||
// User
|
||||
'user.admin': 'Administrator',
|
||||
'user.user': 'Utilizator',
|
||||
|
||||
// Documents
|
||||
'nav.documents': 'Documente',
|
||||
'documents.title': 'Documente',
|
||||
'documents.uploadTitle': 'Încarcă Documente',
|
||||
'documents.dragDrop': 'Trage și plasează fișiere aici sau click pentru a căuta',
|
||||
'documents.uploadDesc': 'Încarcă extrase de cont, facturi sau chitanțe.',
|
||||
'documents.supportedFormats': 'Formate suportate: CSV, PDF, XLS, XLSX, PNG, JPG (Max 10MB)',
|
||||
'documents.yourFiles': 'Fișierele Tale',
|
||||
'documents.searchPlaceholder': 'Caută după nume...',
|
||||
'documents.tableDocName': 'Nume Document',
|
||||
'documents.tableUploadDate': 'Data Încărcării',
|
||||
'documents.tableType': 'Tip',
|
||||
'documents.tableStatus': 'Stare',
|
||||
'documents.tableActions': 'Acțiuni',
|
||||
'documents.statusUploaded': 'Încărcat',
|
||||
'documents.statusProcessing': 'În procesare',
|
||||
'documents.statusAnalyzed': 'Analizat',
|
||||
'documents.statusError': 'Eroare',
|
||||
'documents.showing': 'Afișare',
|
||||
'documents.of': 'din',
|
||||
'documents.documents': 'documente',
|
||||
'documents.noDocuments': 'Nu s-au găsit documente. Încarcă primul tău document!',
|
||||
'documents.errorLoading': 'Eroare la încărcarea documentelor. Te rugăm încearcă din nou.',
|
||||
|
||||
// Settings
|
||||
'settings.title': 'Setări',
|
||||
'settings.avatar': 'Avatar Profil',
|
||||
'settings.uploadAvatar': 'Încarcă Personalizat',
|
||||
'settings.avatarDesc': 'PNG, JPG, GIF, WEBP. Max 20MB',
|
||||
'settings.defaultAvatars': 'Sau alege un avatar prestabilit:',
|
||||
'settings.profile': 'Informații Profil',
|
||||
'settings.saveProfile': 'Salvează Profil',
|
||||
'settings.changePassword': 'Schimbă Parola',
|
||||
'settings.currentPassword': 'Parola Curentă',
|
||||
'settings.newPassword': 'Parolă Nouă',
|
||||
'settings.confirmPassword': 'Confirmă Parola Nouă',
|
||||
'settings.updatePassword': 'Actualizează Parola',
|
||||
'settings.twoFactor': 'Autentificare Doi Factori',
|
||||
'settings.twoFactorEnabled': '2FA este activată pentru contul tău',
|
||||
'settings.twoFactorDisabled': 'Adaugă un nivel suplimentar de securitate contului tău',
|
||||
'settings.enabled': 'Activat',
|
||||
'settings.disabled': 'Dezactivat',
|
||||
'settings.regenerateCodes': 'Regenerează Coduri Backup',
|
||||
'settings.enable2FA': 'Activează 2FA',
|
||||
'settings.disable2FA': 'Dezactivează 2FA',
|
||||
|
||||
// Two-Factor Authentication
|
||||
'twofa.setupTitle': 'Configurare Autentificare Doi Factori',
|
||||
'twofa.setupDesc': 'Scanează codul QR cu aplicația ta de autentificare',
|
||||
'twofa.step1': 'Pasul 1: Scanează Codul QR',
|
||||
'twofa.step1Desc': 'Deschide aplicația ta de autentificare (Google Authenticator, Authy, etc.) și scanează acest cod QR:',
|
||||
'twofa.manualEntry': 'Nu poți scana? Introdu codul manual',
|
||||
'twofa.enterManually': 'Introdu acest cod în aplicația ta de autentificare:',
|
||||
'twofa.step2': 'Pasul 2: Verifică Codul',
|
||||
'twofa.step2Desc': 'Introdu codul de 6 cifre din aplicația ta de autentificare:',
|
||||
'twofa.enable': 'Activează 2FA',
|
||||
'twofa.infoText': 'După activarea 2FA, vei primi coduri de backup pe care le poți folosi dacă pierzi accesul la aplicația ta de autentificare. Păstrează-le într-un loc sigur!',
|
||||
'twofa.setupSuccess': 'Autentificare Doi Factori Activată!',
|
||||
'twofa.backupCodesDesc': 'Salvează aceste coduri de backup într-o locație sigură',
|
||||
'twofa.important': 'Important!',
|
||||
'twofa.backupCodesWarning': 'Fiecare cod de backup poate fi folosit o singură dată. Păstrează-le în siguranță - vei avea nevoie de ele dacă pierzi accesul la aplicația ta de autentificare.',
|
||||
'twofa.yourBackupCodes': 'Codurile Tale de Backup',
|
||||
'twofa.downloadPDF': 'Descarcă ca PDF',
|
||||
'twofa.print': 'Tipărește Coduri',
|
||||
'twofa.continueToSettings': 'Continuă la Setări',
|
||||
'twofa.howToUse': 'Cum să folosești codurile de backup:',
|
||||
'twofa.useWhen': 'Folosește un cod de backup când nu poți accesa aplicația ta de autentificare',
|
||||
'twofa.enterCode': 'Introdu codul în câmpul 2FA când te autentifici',
|
||||
'twofa.oneTimeUse': 'Fiecare cod funcționează o singură dată - va fi șters după folosire',
|
||||
'twofa.regenerate': 'Poți regenera coduri oricând din Setări',
|
||||
|
||||
// Admin
|
||||
'admin.title': 'Panou Administrare',
|
||||
'admin.subtitle': 'Gestionează utilizatori și setări sistem',
|
||||
'admin.totalUsers': 'Total Utilizatori',
|
||||
'admin.adminUsers': 'Administratori',
|
||||
'admin.twoFAEnabled': '2FA Activat',
|
||||
'admin.users': 'Utilizatori',
|
||||
'admin.createUser': 'Creează Utilizator',
|
||||
'admin.username': 'Nume Utilizator',
|
||||
'admin.email': 'Email',
|
||||
'admin.role': 'Rol',
|
||||
'admin.twoFA': '2FA',
|
||||
'admin.language': 'Limbă',
|
||||
'admin.currency': 'Monedă',
|
||||
'admin.joined': 'Înregistrat',
|
||||
'admin.actions': 'Acțiuni',
|
||||
'admin.admin': 'Admin',
|
||||
'admin.user': 'Utilizator',
|
||||
'admin.createNewUser': 'Creează Utilizator Nou',
|
||||
'admin.makeAdmin': 'Fă administrator',
|
||||
'admin.create': 'Creează',
|
||||
'admin.noUsers': 'Niciun utilizator găsit',
|
||||
'admin.errorLoading': 'Eroare la încărcarea utilizatorilor',
|
||||
'admin.userCreated': 'Utilizator creat cu succes',
|
||||
'admin.errorCreating': 'Eroare la crearea utilizatorului',
|
||||
'admin.confirmDelete': 'Sigur vrei să ștergi utilizatorul',
|
||||
'admin.userDeleted': 'Utilizator șters cu succes',
|
||||
'admin.errorDeleting': 'Eroare la ștergerea utilizatorului',
|
||||
'admin.editNotImplemented': 'Funcționalitatea de editare va fi disponibilă în curând',
|
||||
|
||||
// Common
|
||||
'common.cancel': 'Anulează',
|
||||
'common.edit': 'Editează',
|
||||
'common.delete': 'Șterge',
|
||||
// Categorii
|
||||
'categories.foodDining': 'Mâncare & Restaurant',
|
||||
'categories.transportation': 'Transport',
|
||||
'categories.shopping': 'Cumpărături',
|
||||
'categories.entertainment': 'Divertisment',
|
||||
'categories.billsUtilities': 'Facturi & Utilități',
|
||||
'categories.healthcare': 'Sănătate',
|
||||
'categories.education': 'Educație',
|
||||
'categories.other': 'Altele',
|
||||
'categories.manageTitle': 'Gestionează Categorii',
|
||||
'categories.addNew': 'Adaugă Categorie Nouă',
|
||||
'categories.add': 'Adaugă',
|
||||
'categories.yourCategories': 'Categoriile Tale',
|
||||
'categories.dragToReorder': 'Trage pentru a reordona',
|
||||
'categories.created': 'Categorie creată cu succes',
|
||||
'categories.updated': 'Categorie actualizată cu succes',
|
||||
'categories.deleted': 'Categorie ștearsă cu succes',
|
||||
'categories.hasExpenses': 'Nu se poate șterge categoria cu cheltuieli',
|
||||
'categories.reordered': 'Categorii reordonate cu succes',
|
||||
|
||||
// Tablou de bord
|
||||
'dashboard.expenseCategories': 'Categorii de Cheltuieli',
|
||||
'dashboard.manageCategories': 'Gestionează',
|
||||
|
||||
// Formatare dată
|
||||
'date.today': 'Astăzi',
|
||||
'date.yesterday': 'Ieri',
|
||||
'date.daysAgo': 'zile în urmă',
|
||||
|
||||
// Formular
|
||||
'form.name': 'Nume',
|
||||
'form.color': 'Culoare',
|
||||
'form.icon': 'Iconă',
|
||||
|
||||
// Comune
|
||||
'common.cancel': 'Anulează',
|
||||
'common.edit': 'Editează',
|
||||
'common.delete': 'Șterge',
|
||||
'common.error': 'A apărut o eroare. Te rugăm încearcă din nou.',
|
||||
'common.success': 'Operațiune finalizată cu succes!',
|
||||
'common.missingFields': 'Câmpuri obligatorii lipsă',
|
||||
'common.invalidCategory': 'Categorie invalidă',
|
||||
// Actions
|
||||
'actions.cancel': 'Anulează'
|
||||
}
|
||||
};
|
||||
|
||||
// Get current language from localStorage or default to 'en'
|
||||
function getCurrentLanguage() {
|
||||
return localStorage.getItem('language') || 'en';
|
||||
}
|
||||
|
||||
// Set language
|
||||
function setLanguage(lang) {
|
||||
if (translations[lang]) {
|
||||
localStorage.setItem('language', lang);
|
||||
translatePage(lang);
|
||||
}
|
||||
}
|
||||
|
||||
// Translate all elements on page
|
||||
function translatePage(lang) {
|
||||
const elements = document.querySelectorAll('[data-translate]');
|
||||
|
||||
elements.forEach(element => {
|
||||
const key = element.getAttribute('data-translate');
|
||||
const translation = translations[lang][key];
|
||||
|
||||
if (translation) {
|
||||
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
||||
element.placeholder = translation;
|
||||
} else {
|
||||
element.textContent = translation;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize translations on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const currentLang = getCurrentLanguage();
|
||||
translatePage(currentLang);
|
||||
});
|
||||
|
||||
// Helper function to get translated text
|
||||
function getTranslation(key, fallback = '') {
|
||||
const lang = getCurrentLanguage();
|
||||
return translations[lang]?.[key] || fallback || key;
|
||||
}
|
||||
|
||||
// Make functions and translations globally accessible for other scripts
|
||||
window.getCurrentLanguage = getCurrentLanguage;
|
||||
window.setLanguage = setLanguage;
|
||||
window.translatePage = translatePage;
|
||||
window.translations = translations;
|
||||
window.getTranslation = getTranslation;
|
||||
|
||||
// Export functions for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { getCurrentLanguage, setLanguage, translatePage, translations };
|
||||
}
|
||||
54
backup/fina-2/app/static/js/pwa.js
Normal file
54
backup/fina-2/app/static/js/pwa.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// PWA Service Worker Registration
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/sw.js')
|
||||
.then(registration => {
|
||||
console.log('ServiceWorker registered:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('ServiceWorker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Install prompt
|
||||
let deferredPrompt;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button if you have one
|
||||
const installBtn = document.getElementById('install-btn');
|
||||
if (installBtn) {
|
||||
installBtn.style.display = 'block';
|
||||
|
||||
installBtn.addEventListener('click', () => {
|
||||
installBtn.style.display = 'none';
|
||||
deferredPrompt.prompt();
|
||||
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the install prompt');
|
||||
}
|
||||
deferredPrompt = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check if app is installed
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('FINA has been installed');
|
||||
showToast('FINA installed successfully!', 'success');
|
||||
});
|
||||
|
||||
// Online/Offline status
|
||||
window.addEventListener('online', () => {
|
||||
showToast('You are back online', 'success');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
showToast('You are offline. Some features may be limited.', 'warning');
|
||||
});
|
||||
429
backup/fina-2/app/static/js/reports.js
Normal file
429
backup/fina-2/app/static/js/reports.js
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
// Reports page JavaScript
|
||||
|
||||
let currentPeriod = 30;
|
||||
let categoryFilter = '';
|
||||
let trendChart = null;
|
||||
let categoryChart = null;
|
||||
let monthlyChart = null;
|
||||
|
||||
// Load reports data
|
||||
async function loadReportsData() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
period: currentPeriod,
|
||||
...(categoryFilter && { category_id: categoryFilter })
|
||||
});
|
||||
|
||||
const data = await apiCall(`/api/reports-stats?${params}`);
|
||||
displayReportsData(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load reports data:', error);
|
||||
showToast('Failed to load reports', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Display reports data
|
||||
function displayReportsData(data) {
|
||||
// Store user currency globally
|
||||
window.userCurrency = data.currency || 'RON';
|
||||
|
||||
// Update KPI cards
|
||||
document.getElementById('total-spent').textContent = formatCurrency(data.total_spent, window.userCurrency);
|
||||
|
||||
// Spending change indicator
|
||||
const spentChange = document.getElementById('spent-change');
|
||||
const changeValue = data.percent_change;
|
||||
const isIncrease = changeValue > 0;
|
||||
spentChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
|
||||
isIncrease
|
||||
? 'text-red-500 dark:text-red-400 bg-red-500/10'
|
||||
: 'text-green-500 dark:text-green-400 bg-green-500/10'
|
||||
}`;
|
||||
spentChange.innerHTML = `
|
||||
<span class="material-symbols-outlined text-[14px] mr-0.5">${isIncrease ? 'trending_up' : 'trending_down'}</span>
|
||||
${Math.abs(changeValue).toFixed(1)}%
|
||||
`;
|
||||
|
||||
// Top category
|
||||
document.getElementById('top-category').textContent = data.top_category.name;
|
||||
document.getElementById('top-category-amount').textContent = formatCurrency(data.top_category.amount, data.currency);
|
||||
|
||||
// Average daily
|
||||
document.getElementById('avg-daily').textContent = formatCurrency(data.avg_daily, data.currency);
|
||||
|
||||
// Average change indicator
|
||||
const avgChange = document.getElementById('avg-change');
|
||||
const avgChangeValue = data.avg_daily_change;
|
||||
const isAvgIncrease = avgChangeValue > 0;
|
||||
avgChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
|
||||
isAvgIncrease
|
||||
? 'text-red-500 dark:text-red-400 bg-red-500/10'
|
||||
: 'text-green-500 dark:text-green-400 bg-green-500/10'
|
||||
}`;
|
||||
avgChange.innerHTML = `
|
||||
<span class="material-symbols-outlined text-[14px] mr-0.5">${isAvgIncrease ? 'trending_up' : 'trending_down'}</span>
|
||||
${Math.abs(avgChangeValue).toFixed(1)}%
|
||||
`;
|
||||
|
||||
// Savings rate
|
||||
document.getElementById('savings-rate').textContent = `${data.savings_rate}%`;
|
||||
|
||||
// Update charts
|
||||
updateTrendChart(data.daily_trend);
|
||||
updateCategoryChart(data.category_breakdown);
|
||||
updateMonthlyChart(data.monthly_comparison);
|
||||
}
|
||||
|
||||
// Update trend chart
|
||||
function updateTrendChart(dailyData) {
|
||||
const ctx = document.getElementById('trend-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
// Get theme
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const textColor = isDark ? '#94a3b8' : '#64748b';
|
||||
const gridColor = isDark ? '#334155' : '#e2e8f0';
|
||||
|
||||
if (trendChart) {
|
||||
trendChart.destroy();
|
||||
}
|
||||
|
||||
trendChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dailyData.map(d => d.date),
|
||||
datasets: [{
|
||||
label: 'Daily Spending',
|
||||
data: dailyData.map(d => d.amount),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
pointBorderColor: '#3b82f6',
|
||||
pointBorderWidth: 2,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
titleColor: isDark ? '#f8fafc' : '#0f172a',
|
||||
bodyColor: isDark ? '#94a3b8' : '#64748b',
|
||||
borderColor: isDark ? '#334155' : '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return formatCurrency(context.parsed.y, window.userCurrency || 'RON');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: gridColor,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
maxRotation: 45,
|
||||
minRotation: 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: gridColor,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
callback: function(value) {
|
||||
return '$' + value.toFixed(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update category pie chart - Beautiful CSS conic-gradient design
|
||||
function updateCategoryChart(categories) {
|
||||
const pieChart = document.getElementById('category-pie-chart');
|
||||
const pieTotal = document.getElementById('category-pie-total');
|
||||
const pieLegend = document.getElementById('category-legend');
|
||||
|
||||
if (!pieChart || !pieLegend) return;
|
||||
|
||||
const userCurrency = window.userCurrency || 'RON';
|
||||
|
||||
if (categories.length === 0) {
|
||||
pieChart.style.background = 'conic-gradient(#233648 0% 100%)';
|
||||
if (pieTotal) pieTotal.textContent = formatCurrency(0, userCurrency);
|
||||
pieLegend.innerHTML = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9] text-sm">No data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
const total = categories.reduce((sum, cat) => sum + parseFloat(cat.amount || 0), 0);
|
||||
if (pieTotal) pieTotal.textContent = formatCurrency(total, userCurrency);
|
||||
|
||||
// Generate conic gradient segments
|
||||
let currentPercent = 0;
|
||||
const gradientSegments = categories.map(cat => {
|
||||
const percent = total > 0 ? (parseFloat(cat.amount || 0) / total) * 100 : 0;
|
||||
const segment = `${cat.color} ${currentPercent}% ${currentPercent + percent}%`;
|
||||
currentPercent += percent;
|
||||
return segment;
|
||||
});
|
||||
|
||||
// Apply gradient
|
||||
pieChart.style.background = `conic-gradient(${gradientSegments.join(', ')})`;
|
||||
|
||||
// Generate compact legend
|
||||
const legendHTML = categories.map(cat => {
|
||||
const percent = total > 0 ? ((parseFloat(cat.amount || 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('');
|
||||
|
||||
pieLegend.innerHTML = legendHTML;
|
||||
}
|
||||
|
||||
// Update monthly chart
|
||||
function updateMonthlyChart(monthlyData) {
|
||||
const ctx = document.getElementById('monthly-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const textColor = isDark ? '#94a3b8' : '#64748b';
|
||||
const gridColor = isDark ? '#334155' : '#e2e8f0';
|
||||
|
||||
if (monthlyChart) {
|
||||
monthlyChart.destroy();
|
||||
}
|
||||
|
||||
monthlyChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: monthlyData.map(d => d.month),
|
||||
datasets: [{
|
||||
label: 'Monthly Spending',
|
||||
data: monthlyData.map(d => d.amount),
|
||||
backgroundColor: '#2b8cee',
|
||||
borderRadius: 6,
|
||||
barPercentage: 0.5, // Slim bars
|
||||
categoryPercentage: 0.7, // Tighter spacing
|
||||
hoverBackgroundColor: '#1d7ad9'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a2632' : '#ffffff',
|
||||
titleColor: isDark ? '#ffffff' : '#1a2632',
|
||||
bodyColor: isDark ? '#92adc9' : '#64748b',
|
||||
borderColor: isDark ? '#233648' : '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return formatCurrency(context.parsed.y, window.userCurrency || 'RON');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
minRotation: 0
|
||||
},
|
||||
border: { display: false }
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: gridColor,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 11 },
|
||||
maxTicksLimit: 6,
|
||||
callback: function(value) {
|
||||
return formatCurrency(value, window.userCurrency || 'RON');
|
||||
}
|
||||
},
|
||||
border: { display: false }
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
left: 5,
|
||||
right: 5,
|
||||
top: 5,
|
||||
bottom: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load categories for filter
|
||||
async function loadCategoriesFilter() {
|
||||
try {
|
||||
const data = await apiCall('/api/expenses/categories');
|
||||
const select = document.getElementById('category-filter');
|
||||
|
||||
const categoriesHTML = data.categories.map(cat =>
|
||||
`<option value="${cat.id}">${cat.name}</option>`
|
||||
).join('');
|
||||
|
||||
select.innerHTML = '<option value="">All Categories</option>' + categoriesHTML;
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Period button handlers
|
||||
document.querySelectorAll('.period-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
// Remove active class from all buttons
|
||||
document.querySelectorAll('.period-btn').forEach(b => {
|
||||
b.classList.remove('active', 'text-white', 'dark:text-white', 'bg-white/10', 'shadow-sm');
|
||||
b.classList.add('text-text-muted', 'dark:text-[#92adc9]', 'hover:text-text-main', 'dark:hover:text-white', 'hover:bg-white/5');
|
||||
});
|
||||
|
||||
// Add active class to clicked button
|
||||
btn.classList.add('active', 'text-white', 'dark:text-white', 'bg-white/10', 'shadow-sm');
|
||||
btn.classList.remove('text-text-muted', 'dark:text-[#92adc9]', 'hover:text-text-main', 'dark:hover:text-white', 'hover:bg-white/5');
|
||||
|
||||
currentPeriod = btn.dataset.period;
|
||||
loadReportsData();
|
||||
});
|
||||
});
|
||||
|
||||
// Category filter handler
|
||||
document.getElementById('category-filter').addEventListener('change', (e) => {
|
||||
categoryFilter = e.target.value;
|
||||
});
|
||||
|
||||
// Generate report button
|
||||
document.getElementById('generate-report-btn').addEventListener('click', () => {
|
||||
loadReportsData();
|
||||
});
|
||||
|
||||
// Export report button
|
||||
document.getElementById('export-report-btn').addEventListener('click', () => {
|
||||
window.location.href = '/api/expenses/export/csv';
|
||||
});
|
||||
|
||||
// Handle theme changes - reload charts with new theme colors
|
||||
function handleThemeChange() {
|
||||
if (trendChart || categoryChart || monthlyChart) {
|
||||
loadReportsData();
|
||||
}
|
||||
}
|
||||
|
||||
// Load smart recommendations
|
||||
async function loadRecommendations() {
|
||||
const container = document.getElementById('recommendations-container');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const data = await apiCall('/api/smart-recommendations');
|
||||
|
||||
if (!data.success || !data.recommendations || data.recommendations.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[32px]">lightbulb</span>
|
||||
<p class="text-sm text-text-muted dark:text-[#92adc9]" data-translate="reports.noRecommendations">No recommendations at this time</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const recommendationsHTML = data.recommendations.map(rec => {
|
||||
// Type-based colors
|
||||
const colorClasses = {
|
||||
'warning': 'border-yellow-500/20 bg-yellow-500/5 hover:bg-yellow-500/10',
|
||||
'success': 'border-green-500/20 bg-green-500/5 hover:bg-green-500/10',
|
||||
'info': 'border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10',
|
||||
'danger': 'border-red-500/20 bg-red-500/5 hover:bg-red-500/10'
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
'warning': 'text-yellow-500',
|
||||
'success': 'text-green-500',
|
||||
'info': 'text-blue-500',
|
||||
'danger': 'text-red-500'
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="flex items-start gap-4 p-4 rounded-lg border ${colorClasses[rec.type] || 'border-border-light dark:border-[#233648]'} transition-all">
|
||||
<span class="material-symbols-outlined ${iconColors[rec.type] || 'text-primary'} text-[28px] flex-shrink-0 mt-0.5">${rec.icon}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-semibold text-text-main dark:text-white mb-1">${rec.title}</h4>
|
||||
<p class="text-xs text-text-muted dark:text-[#92adc9] leading-relaxed">${rec.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = recommendationsHTML;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load recommendations:', error);
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<p class="text-sm text-red-500">Failed to load recommendations</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for theme toggle events
|
||||
window.addEventListener('theme-changed', handleThemeChange);
|
||||
|
||||
// Listen for storage changes (for multi-tab sync)
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'theme') {
|
||||
handleThemeChange();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadReportsData();
|
||||
loadCategoriesFilter();
|
||||
loadRecommendations();
|
||||
});
|
||||
274
backup/fina-2/app/static/js/settings.js
Normal file
274
backup/fina-2/app/static/js/settings.js
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// Settings Page Functionality
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupAvatarHandlers();
|
||||
setupProfileHandlers();
|
||||
setupPasswordHandlers();
|
||||
});
|
||||
|
||||
// Avatar upload and selection
|
||||
function setupAvatarHandlers() {
|
||||
const uploadBtn = document.getElementById('upload-avatar-btn');
|
||||
const avatarInput = document.getElementById('avatar-upload');
|
||||
const currentAvatar = document.getElementById('current-avatar');
|
||||
const sidebarAvatar = document.getElementById('sidebar-avatar');
|
||||
const defaultAvatarBtns = document.querySelectorAll('.default-avatar-btn');
|
||||
|
||||
// Trigger file input when upload button clicked
|
||||
if (uploadBtn && avatarInput) {
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
avatarInput.click();
|
||||
});
|
||||
|
||||
// Handle file selection
|
||||
avatarInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
showNotification('error', 'Invalid file type. Please use PNG, JPG, GIF, or WEBP.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (20MB)
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
showNotification('error', 'File too large. Maximum size is 20MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload avatar
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// Update avatar displays
|
||||
const avatarUrl = result.avatar.startsWith('icons/')
|
||||
? `/static/${result.avatar}?t=${Date.now()}`
|
||||
: `/${result.avatar}?t=${Date.now()}`;
|
||||
currentAvatar.src = avatarUrl;
|
||||
if (sidebarAvatar) sidebarAvatar.src = avatarUrl;
|
||||
|
||||
showNotification('success', result.message || 'Avatar updated successfully!');
|
||||
} else {
|
||||
showNotification('error', result.error || 'Failed to upload avatar');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
showNotification('error', 'An error occurred during upload');
|
||||
}
|
||||
|
||||
// Reset input
|
||||
avatarInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle default avatar selection
|
||||
defaultAvatarBtns.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const avatarPath = btn.getAttribute('data-avatar');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/avatar/default', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ avatar: avatarPath })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// Update avatar displays
|
||||
const avatarUrl = result.avatar.startsWith('icons/')
|
||||
? `/static/${result.avatar}?t=${Date.now()}`
|
||||
: `/${result.avatar}?t=${Date.now()}`;
|
||||
currentAvatar.src = avatarUrl;
|
||||
if (sidebarAvatar) sidebarAvatar.src = avatarUrl;
|
||||
|
||||
// Update active state
|
||||
defaultAvatarBtns.forEach(b => b.classList.remove('border-primary'));
|
||||
btn.classList.add('border-primary');
|
||||
|
||||
showNotification('success', result.message || 'Avatar updated successfully!');
|
||||
} else {
|
||||
showNotification('error', result.error || 'Failed to update avatar');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
showNotification('error', 'An error occurred');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Profile update handlers
|
||||
function setupProfileHandlers() {
|
||||
const saveBtn = document.getElementById('save-profile-btn');
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const language = document.getElementById('language').value;
|
||||
const currency = document.getElementById('currency').value;
|
||||
const monthlyBudget = document.getElementById('monthly-budget').value;
|
||||
|
||||
if (!username || !email) {
|
||||
showNotification('error', 'Username and email are required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
showNotification('error', 'Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
// Budget validation
|
||||
const budget = parseFloat(monthlyBudget);
|
||||
if (isNaN(budget) || budget < 0) {
|
||||
showNotification('error', 'Please enter a valid budget amount');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email,
|
||||
language,
|
||||
currency,
|
||||
monthly_budget: budget
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showNotification('success', result.message || 'Profile updated successfully!');
|
||||
|
||||
// Update language if changed
|
||||
const currentLang = getCurrentLanguage();
|
||||
if (language !== currentLang) {
|
||||
setLanguage(language);
|
||||
// Reload page to apply translations
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
showNotification('error', result.error || 'Failed to update profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
showNotification('error', 'An error occurred');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Password change handlers
|
||||
function setupPasswordHandlers() {
|
||||
const changeBtn = document.getElementById('change-password-btn');
|
||||
|
||||
if (changeBtn) {
|
||||
changeBtn.addEventListener('click', async () => {
|
||||
const currentPassword = document.getElementById('current-password').value;
|
||||
const newPassword = document.getElementById('new-password').value;
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
showNotification('error', 'All password fields are required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showNotification('error', 'New password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showNotification('error', 'New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showNotification('success', result.message || 'Password changed successfully!');
|
||||
|
||||
// Clear form
|
||||
document.getElementById('current-password').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
document.getElementById('confirm-password').value = '';
|
||||
} else {
|
||||
showNotification('error', result.error || 'Failed to change password');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
showNotification('error', 'An error occurred');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(type, message) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slideIn ${
|
||||
type === 'success'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-red-500 text-white'
|
||||
}`;
|
||||
|
||||
notification.innerHTML = `
|
||||
<span class="material-symbols-outlined text-[20px]">
|
||||
${type === 'success' ? 'check_circle' : 'error'}
|
||||
</span>
|
||||
<span class="text-sm font-medium">${escapeHtml(message)}</span>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('animate-slideOut');
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
564
backup/fina-2/app/static/js/transactions.js
Normal file
564
backup/fina-2/app/static/js/transactions.js
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
// Transactions page JavaScript
|
||||
|
||||
let currentPage = 1;
|
||||
let filters = {
|
||||
category_id: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
search: ''
|
||||
};
|
||||
|
||||
// Load user profile to get currency
|
||||
async function loadUserCurrency() {
|
||||
try {
|
||||
const profile = await apiCall('/api/settings/profile');
|
||||
window.userCurrency = profile.profile.currency || 'RON';
|
||||
} catch (error) {
|
||||
console.error('Failed to load user currency:', error);
|
||||
window.userCurrency = 'RON';
|
||||
}
|
||||
}
|
||||
|
||||
// Load transactions
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
...filters
|
||||
});
|
||||
|
||||
const data = await apiCall(`/api/expenses/?${params}`);
|
||||
displayTransactions(data.expenses);
|
||||
displayPagination(data.pages, data.current_page, data.total || data.expenses.length);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Display transactions
|
||||
function displayTransactions(transactions) {
|
||||
const container = document.getElementById('transactions-list');
|
||||
|
||||
if (transactions.length === 0) {
|
||||
const noTransactionsText = window.getTranslation ? window.getTranslation('transactions.noTransactions', 'No transactions found') : 'No transactions found';
|
||||
container.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="p-12 text-center">
|
||||
<span class="material-symbols-outlined text-6xl text-[#92adc9] mb-4 block">receipt_long</span>
|
||||
<p class="text-[#92adc9] text-lg">${noTransactionsText}</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = transactions.map(tx => {
|
||||
const txDate = new Date(tx.date);
|
||||
const dateStr = txDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const timeStr = txDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
|
||||
// Get category color
|
||||
const categoryColors = {
|
||||
'Food': { bg: 'bg-green-500/10', text: 'text-green-400', border: 'border-green-500/20', dot: 'bg-green-400' },
|
||||
'Transport': { bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/20', dot: 'bg-orange-400' },
|
||||
'Entertainment': { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20', dot: 'bg-purple-400' },
|
||||
'Shopping': { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20', dot: 'bg-blue-400' },
|
||||
'Healthcare': { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/20', dot: 'bg-red-400' },
|
||||
'Bills': { bg: 'bg-yellow-500/10', text: 'text-yellow-400', border: 'border-yellow-500/20', dot: 'bg-yellow-400' },
|
||||
'Education': { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/20', dot: 'bg-pink-400' },
|
||||
'Other': { bg: 'bg-gray-500/10', text: 'text-gray-400', border: 'border-gray-500/20', dot: 'bg-gray-400' }
|
||||
};
|
||||
const catColor = categoryColors[tx.category_name] || categoryColors['Other'];
|
||||
|
||||
// Status icon (completed/pending)
|
||||
const isCompleted = true; // For now, all are completed
|
||||
const statusIcon = isCompleted
|
||||
? '<span class="material-symbols-outlined text-[16px]">check</span>'
|
||||
: '<span class="material-symbols-outlined text-[16px]">schedule</span>';
|
||||
const statusClass = isCompleted
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400';
|
||||
const statusTitle = isCompleted
|
||||
? (window.getTranslation ? window.getTranslation('transactions.completed', 'Completed') : 'Completed')
|
||||
: (window.getTranslation ? window.getTranslation('transactions.pending', 'Pending') : 'Pending');
|
||||
|
||||
return `
|
||||
<tr class="group hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors relative border-l-2 border-transparent hover:border-primary">
|
||||
<td class="p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="size-10 rounded-full flex items-center justify-center shrink-0" style="background: ${tx.category_color}20;">
|
||||
<span class="material-symbols-outlined text-[20px]" style="color: ${tx.category_color};">payments</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-text-main dark:text-white font-medium group-hover:text-primary transition-colors">${tx.description}</span>
|
||||
<span class="text-text-muted dark:text-[#92adc9] text-xs">${tx.tags.length > 0 ? tx.tags.join(', ') : (window.getTranslation ? window.getTranslation('transactions.expense', 'Expense') : 'Expense')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-5">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${catColor.bg} ${catColor.text} border ${catColor.border}">
|
||||
<span class="size-1.5 rounded-full ${catColor.dot}"></span>
|
||||
${tx.category_name}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-5 text-text-muted dark:text-[#92adc9]">
|
||||
${dateStr}
|
||||
<span class="block text-xs opacity-60">${timeStr}</span>
|
||||
</td>
|
||||
<td class="p-5">
|
||||
<div class="flex items-center gap-2 text-text-main dark:text-white">
|
||||
<span class="material-symbols-outlined text-text-muted dark:text-[#92adc9] text-[18px]">credit_card</span>
|
||||
<span>•••• ${window.userCurrency || 'RON'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-5 text-right">
|
||||
<span class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, window.userCurrency || 'RON')}</span>
|
||||
</td>
|
||||
<td class="p-5 text-center">
|
||||
<span class="inline-flex items-center justify-center size-6 rounded-full ${statusClass}" title="${statusTitle}">
|
||||
${statusIcon}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${tx.receipt_path ? `
|
||||
<button onclick="viewReceipt('${tx.receipt_path}')" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="${window.getTranslation ? window.getTranslation('transactions.viewReceipt', 'View Receipt') : 'View Receipt'}">
|
||||
<span class="material-symbols-outlined text-[18px]">attach_file</span>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="editTransaction(${tx.id})" class="text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="${window.getTranslation ? window.getTranslation('transactions.edit', 'Edit') : 'Edit'}">
|
||||
<span class="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
<button onclick="deleteTransaction(${tx.id})" class="text-text-muted dark:text-[#92adc9] hover:text-red-400 p-1 rounded hover:bg-red-100 dark:hover:bg-white/10 transition-colors" title="${window.getTranslation ? window.getTranslation('transactions.delete', 'Delete') : 'Delete'}">
|
||||
<span class="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Display pagination
|
||||
function displayPagination(totalPages, current, totalItems = 0) {
|
||||
const container = document.getElementById('pagination');
|
||||
|
||||
// Update pagination info
|
||||
const perPage = 10;
|
||||
const start = (current - 1) * perPage + 1;
|
||||
const end = Math.min(current * perPage, totalItems);
|
||||
|
||||
document.getElementById('page-start').textContent = totalItems > 0 ? start : 0;
|
||||
document.getElementById('page-end').textContent = end;
|
||||
document.getElementById('total-count').textContent = totalItems;
|
||||
|
||||
if (totalPages <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// Previous button
|
||||
const prevDisabled = current <= 1;
|
||||
const prevText = window.getTranslation ? window.getTranslation('transactions.previous', 'Previous') : 'Previous';
|
||||
const nextText = window.getTranslation ? window.getTranslation('transactions.next', 'Next') : 'Next';
|
||||
|
||||
html += `
|
||||
<button
|
||||
onclick="changePage(${current - 1})"
|
||||
class="flex items-center gap-1 px-3 py-1.5 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-md text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-text-muted dark:hover:border-[#92adc9] transition-colors text-sm ${prevDisabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
${prevDisabled ? 'disabled' : ''}
|
||||
>
|
||||
<span class="material-symbols-outlined text-[16px]">chevron_left</span>
|
||||
${prevText}
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Next button
|
||||
const nextDisabled = current >= totalPages;
|
||||
html += `
|
||||
<button
|
||||
onclick="changePage(${current + 1})"
|
||||
class="flex items-center gap-1 px-3 py-1.5 bg-background-light dark:bg-[#111a22] border border-border-light dark:border-[#233648] rounded-md text-text-muted dark:text-[#92adc9] hover:text-text-main dark:hover:text-white hover:border-text-muted dark:hover:border-[#92adc9] transition-colors text-sm ${nextDisabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
${nextDisabled ? 'disabled' : ''}
|
||||
>
|
||||
${nextText}
|
||||
<span class="material-symbols-outlined text-[16px]">chevron_right</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Change page
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadTransactions();
|
||||
}
|
||||
|
||||
// Edit transaction
|
||||
let currentExpenseId = null;
|
||||
let currentReceiptPath = null;
|
||||
|
||||
async function editTransaction(id) {
|
||||
try {
|
||||
// Fetch expense details
|
||||
const data = await apiCall(`/api/expenses/?page=1`);
|
||||
const expense = data.expenses.find(e => e.id === id);
|
||||
|
||||
if (!expense) {
|
||||
showToast(window.getTranslation ? window.getTranslation('transactions.notFound', 'Transaction not found') : 'Transaction not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store current expense data
|
||||
currentExpenseId = id;
|
||||
currentReceiptPath = expense.receipt_path;
|
||||
|
||||
// Update modal title
|
||||
const modalTitle = document.getElementById('expense-modal-title');
|
||||
modalTitle.textContent = window.getTranslation ? window.getTranslation('modal.edit_expense', 'Edit Expense') : 'Edit Expense';
|
||||
|
||||
// Load categories
|
||||
await loadCategoriesForModal();
|
||||
|
||||
// Populate form fields
|
||||
const form = document.getElementById('expense-form');
|
||||
form.querySelector('[name="amount"]').value = expense.amount;
|
||||
form.querySelector('[name="description"]').value = expense.description;
|
||||
form.querySelector('[name="category_id"]').value = expense.category_id;
|
||||
|
||||
// Format date for input (YYYY-MM-DD)
|
||||
const expenseDate = new Date(expense.date);
|
||||
const dateStr = expenseDate.toISOString().split('T')[0];
|
||||
form.querySelector('[name="date"]').value = dateStr;
|
||||
|
||||
// Populate tags
|
||||
if (expense.tags && expense.tags.length > 0) {
|
||||
form.querySelector('[name="tags"]').value = expense.tags.join(', ');
|
||||
}
|
||||
|
||||
// Show current receipt info if exists
|
||||
const receiptInfo = document.getElementById('current-receipt-info');
|
||||
const viewReceiptBtn = document.getElementById('view-current-receipt');
|
||||
if (expense.receipt_path) {
|
||||
receiptInfo.classList.remove('hidden');
|
||||
viewReceiptBtn.onclick = () => viewReceipt(expense.receipt_path);
|
||||
} else {
|
||||
receiptInfo.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update submit button text
|
||||
const submitBtn = document.getElementById('expense-submit-btn');
|
||||
submitBtn.textContent = window.getTranslation ? window.getTranslation('actions.update', 'Update Expense') : 'Update Expense';
|
||||
|
||||
// Show modal
|
||||
document.getElementById('expense-modal').classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load transaction for editing:', error);
|
||||
showToast(window.getTranslation ? window.getTranslation('common.error', 'An error occurred') : 'An error occurred', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Make editTransaction global
|
||||
window.editTransaction = editTransaction;
|
||||
|
||||
// Delete transaction
|
||||
async function deleteTransaction(id) {
|
||||
const confirmMsg = window.getTranslation ? window.getTranslation('transactions.deleteConfirm', 'Are you sure you want to delete this transaction?') : 'Are you sure you want to delete this transaction?';
|
||||
const successMsg = window.getTranslation ? window.getTranslation('transactions.deleted', 'Transaction deleted') : 'Transaction deleted';
|
||||
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiCall(`/api/expenses/${id}`, { method: 'DELETE' });
|
||||
showToast(successMsg, 'success');
|
||||
loadTransactions();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load categories for filter
|
||||
async function loadCategoriesFilter() {
|
||||
try {
|
||||
const data = await apiCall('/api/expenses/categories');
|
||||
const select = document.getElementById('filter-category');
|
||||
const categoryText = window.getTranslation ? window.getTranslation('transactions.allCategories', 'Category') : 'Category';
|
||||
|
||||
select.innerHTML = `<option value="">${categoryText}</option>` +
|
||||
data.categories.map(cat => `<option value="${cat.id}">${cat.name}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load categories for modal
|
||||
async function loadCategoriesForModal() {
|
||||
try {
|
||||
const data = await apiCall('/api/expenses/categories');
|
||||
const select = document.querySelector('#expense-form [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);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle advanced filters
|
||||
function toggleAdvancedFilters() {
|
||||
const advFilters = document.getElementById('advanced-filters');
|
||||
advFilters.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Filter event listeners
|
||||
document.getElementById('filter-category').addEventListener('change', (e) => {
|
||||
filters.category_id = e.target.value;
|
||||
currentPage = 1;
|
||||
loadTransactions();
|
||||
});
|
||||
|
||||
document.getElementById('filter-start-date').addEventListener('change', (e) => {
|
||||
filters.start_date = e.target.value;
|
||||
currentPage = 1;
|
||||
loadTransactions();
|
||||
});
|
||||
|
||||
document.getElementById('filter-end-date').addEventListener('change', (e) => {
|
||||
filters.end_date = e.target.value;
|
||||
currentPage = 1;
|
||||
loadTransactions();
|
||||
});
|
||||
|
||||
document.getElementById('filter-search').addEventListener('input', (e) => {
|
||||
filters.search = e.target.value;
|
||||
currentPage = 1;
|
||||
loadTransactions();
|
||||
});
|
||||
|
||||
// More filters button
|
||||
document.getElementById('more-filters-btn').addEventListener('click', toggleAdvancedFilters);
|
||||
|
||||
// Date filter button (same as more filters for now)
|
||||
document.getElementById('date-filter-btn').addEventListener('click', toggleAdvancedFilters);
|
||||
|
||||
// Export CSV
|
||||
document.getElementById('export-csv-btn').addEventListener('click', () => {
|
||||
window.location.href = '/api/expenses/export/csv';
|
||||
});
|
||||
|
||||
// Import CSV
|
||||
document.getElementById('import-csv-btn').addEventListener('click', () => {
|
||||
document.getElementById('csv-file-input').click();
|
||||
});
|
||||
|
||||
document.getElementById('csv-file-input').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const result = await apiCall('/api/expenses/import/csv', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const importedText = window.getTranslation ? window.getTranslation('transactions.imported', 'Imported') : 'Imported';
|
||||
const transactionsText = window.getTranslation ? window.getTranslation('transactions.importSuccess', 'transactions') : 'transactions';
|
||||
showToast(`${importedText} ${result.imported} ${transactionsText}`, 'success');
|
||||
if (result.errors.length > 0) {
|
||||
console.warn('Import errors:', result.errors);
|
||||
}
|
||||
loadTransactions();
|
||||
} catch (error) {
|
||||
console.error('Failed to import CSV:', error);
|
||||
}
|
||||
|
||||
e.target.value = ''; // Reset file input
|
||||
});
|
||||
|
||||
// Receipt Viewer
|
||||
const receiptModal = document.getElementById('receipt-modal');
|
||||
const receiptContent = document.getElementById('receipt-content');
|
||||
const closeReceiptModal = document.getElementById('close-receipt-modal');
|
||||
|
||||
function viewReceipt(receiptPath) {
|
||||
const fileExt = receiptPath.split('.').pop().toLowerCase();
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt)) {
|
||||
// Display image
|
||||
receiptContent.innerHTML = `<img src="${receiptPath}" alt="Receipt" class="max-w-full h-auto rounded-lg shadow-lg">`;
|
||||
} else if (fileExt === 'pdf') {
|
||||
// Display PDF
|
||||
receiptContent.innerHTML = `<iframe src="${receiptPath}" class="w-full h-[600px] rounded-lg shadow-lg"></iframe>`;
|
||||
} else {
|
||||
// Unsupported format - provide download link
|
||||
receiptContent.innerHTML = `
|
||||
<div class="text-center">
|
||||
<span class="material-symbols-outlined text-6xl text-text-muted dark:text-[#92adc9] mb-4">description</span>
|
||||
<p class="text-text-main dark:text-white mb-4">Preview not available</p>
|
||||
<a href="${receiptPath}" download class="bg-primary hover:bg-primary/90 text-white px-6 py-3 rounded-lg font-semibold transition-colors inline-block">
|
||||
${window.getTranslation ? window.getTranslation('transactions.downloadReceipt', 'Download Receipt') : 'Download Receipt'}
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
receiptModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
closeReceiptModal.addEventListener('click', () => {
|
||||
receiptModal.classList.add('hidden');
|
||||
receiptContent.innerHTML = '';
|
||||
});
|
||||
|
||||
// Close modal on outside click
|
||||
receiptModal.addEventListener('click', (e) => {
|
||||
if (e.target === receiptModal) {
|
||||
receiptModal.classList.add('hidden');
|
||||
receiptContent.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Expense Modal Event Listeners
|
||||
const expenseModal = document.getElementById('expense-modal');
|
||||
const addExpenseBtn = document.getElementById('add-expense-btn');
|
||||
const closeExpenseModal = document.getElementById('close-expense-modal');
|
||||
const expenseForm = document.getElementById('expense-form');
|
||||
|
||||
// Open modal for adding new expense
|
||||
addExpenseBtn.addEventListener('click', () => {
|
||||
// Reset for add mode
|
||||
currentExpenseId = null;
|
||||
currentReceiptPath = null;
|
||||
expenseForm.reset();
|
||||
|
||||
// Update modal title
|
||||
const modalTitle = document.getElementById('expense-modal-title');
|
||||
modalTitle.textContent = window.getTranslation ? window.getTranslation('modal.add_expense', 'Add Expense') : 'Add Expense';
|
||||
|
||||
// Update submit button
|
||||
const submitBtn = document.getElementById('expense-submit-btn');
|
||||
submitBtn.textContent = window.getTranslation ? window.getTranslation('actions.save', 'Save Expense') : 'Save Expense';
|
||||
|
||||
// Hide receipt info
|
||||
document.getElementById('current-receipt-info').classList.add('hidden');
|
||||
|
||||
// Load categories and set today's date
|
||||
loadCategoriesForModal();
|
||||
const dateInput = expenseForm.querySelector('[name="date"]');
|
||||
dateInput.value = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Show modal
|
||||
expenseModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
// Close modal
|
||||
closeExpenseModal.addEventListener('click', () => {
|
||||
expenseModal.classList.add('hidden');
|
||||
expenseForm.reset();
|
||||
currentExpenseId = null;
|
||||
currentReceiptPath = null;
|
||||
});
|
||||
|
||||
// Close modal on outside click
|
||||
expenseModal.addEventListener('click', (e) => {
|
||||
if (e.target === expenseModal) {
|
||||
expenseModal.classList.add('hidden');
|
||||
expenseForm.reset();
|
||||
currentExpenseId = null;
|
||||
currentReceiptPath = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Submit expense form (handles both add and edit)
|
||||
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));
|
||||
} else {
|
||||
formData.set('tags', JSON.stringify([]));
|
||||
}
|
||||
|
||||
// Convert date to ISO format
|
||||
const date = new Date(formData.get('date'));
|
||||
formData.set('date', date.toISOString());
|
||||
|
||||
// If no file selected in edit mode, remove the empty file field
|
||||
const receiptFile = formData.get('receipt');
|
||||
if (!receiptFile || receiptFile.size === 0) {
|
||||
formData.delete('receipt');
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (currentExpenseId) {
|
||||
// Edit mode - use PUT
|
||||
result = await apiCall(`/api/expenses/${currentExpenseId}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
const successMsg = window.getTranslation ? window.getTranslation('transactions.updated', 'Transaction updated successfully!') : 'Transaction updated successfully!';
|
||||
showToast(successMsg, 'success');
|
||||
} else {
|
||||
// Add mode - use POST
|
||||
result = await apiCall('/api/expenses/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const successMsg = window.getTranslation ? window.getTranslation('dashboard.expenseAdded', 'Expense added successfully!') : 'Expense added successfully!';
|
||||
showToast(successMsg, 'success');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
expenseModal.classList.add('hidden');
|
||||
expenseForm.reset();
|
||||
currentExpenseId = null;
|
||||
currentReceiptPath = null;
|
||||
loadTransactions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save expense:', error);
|
||||
const errorMsg = window.getTranslation ? window.getTranslation('common.error', 'An error occurred') : 'An error occurred';
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadUserCurrency();
|
||||
loadTransactions();
|
||||
loadCategoriesFilter();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue