Initial commit

This commit is contained in:
iulian 2025-12-26 00:52:56 +00:00
commit 983cee0320
322 changed files with 57174 additions and 0 deletions

View file

@ -0,0 +1,168 @@
// 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 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// API helper
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Content-Type': options.body instanceof FormData ? undefined : 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API call failed:', error);
showToast('An error occurred. Please try again.', 'error');
throw error;
}
}
// Theme management
function initTheme() {
// Check for saved theme preference or default to system preference
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
document.documentElement.classList.add('dark');
updateThemeUI(true);
} else {
document.documentElement.classList.remove('dark');
updateThemeUI(false);
}
}
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';
themeText.textContent = 'Dark Mode';
} else {
themeIcon.textContent = 'light_mode';
themeText.textContent = 'Light Mode';
}
}
}
// 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');
}
}
});
}
});

View file

@ -0,0 +1,236 @@
// Dashboard JavaScript
let categoryChart, monthlyChart;
// Load dashboard data
async function loadDashboardData() {
try {
const stats = await apiCall('/api/dashboard-stats');
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(stats.total_spent, stats.currency);
document.getElementById('active-categories').textContent = stats.active_categories;
document.getElementById('total-transactions').textContent = stats.total_transactions;
// Update percent change
const percentChange = document.getElementById('percent-change');
const isPositive = stats.percent_change >= 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(stats.percent_change)}%
`;
// Load charts
loadCategoryChart(stats.category_breakdown);
loadMonthlyChart(stats.monthly_data);
// Load recent transactions
loadRecentTransactions();
} catch (error) {
console.error('Failed to load dashboard data:', error);
}
}
// Category pie chart
function loadCategoryChart(data) {
const ctx = document.getElementById('category-chart').getContext('2d');
if (categoryChart) {
categoryChart.destroy();
}
if (data.length === 0) {
const isDark = document.documentElement.classList.contains('dark');
ctx.fillStyle = isDark ? '#92adc9' : '#64748b';
ctx.font = '14px Inter';
ctx.textAlign = 'center';
ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
return;
}
categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.map(d => d.name),
datasets: [{
data: data.map(d => d.amount),
backgroundColor: data.map(d => d.color),
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b',
padding: 15,
font: { size: 12 }
}
}
}
}
});
}
// Monthly bar chart
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: 'Spending',
data: data.map(d => d.total),
backgroundColor: '#2b8cee',
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b' },
grid: { color: document.documentElement.classList.contains('dark') ? '#233648' : '#e2e8f0' }
},
x: {
ticks: { color: document.documentElement.classList.contains('dark') ? '#92adc9' : '#64748b' },
grid: { display: false }
}
}
}
});
}
// 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) {
container.innerHTML = '<p class="text-[#92adc9] text-sm text-center py-8">No transactions yet</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, tx.currency)}</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);
}
}
// 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"]');
select.innerHTML = '<option value="">Select category...</option>' +
data.categories.map(cat => `<option value="${cat.id}">${cat.name}</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) {
showToast('Expense added successfully!', 'success');
expenseModal.classList.add('hidden');
expenseForm.reset();
loadDashboardData();
}
} catch (error) {
console.error('Failed to add expense:', error);
}
});
// Initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
loadDashboardData();
// Refresh data every 5 minutes
setInterval(loadDashboardData, 5 * 60 * 1000);
});

View file

@ -0,0 +1,442 @@
// 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">
<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();
}
// 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) {
if (!confirm('Are you sure you want to delete this document? This action cannot be undone.')) {
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);
}

View file

@ -0,0 +1,356 @@
// 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',
'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': 'this month',
'dashboard.spending_by_category': 'Spending by Category',
'dashboard.monthly_trend': 'Monthly Trend',
'dashboard.recent_transactions': 'Recent Transactions',
'dashboard.view_all': 'View All',
// 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.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',
// 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',
// 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',
// 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',
'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 aceasta',
'dashboard.spending_by_category': 'Cheltuieli pe Categorii',
'dashboard.monthly_trend': 'Tendință Lunară',
'dashboard.recent_transactions': 'Tranzacții Recente',
'dashboard.view_all': 'Vezi Toate',
// 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.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',
// 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',
// 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',
// 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);
});
// Export functions
if (typeof module !== 'undefined' && module.exports) {
module.exports = { getCurrentLanguage, setLanguage, translatePage, translations };
}

View 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');
});

View file

@ -0,0 +1,367 @@
// 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) {
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(data.total_spent, data.currency);
// 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, 'USD');
}
}
}
},
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
function updateCategoryChart(categories) {
const ctx = document.getElementById('category-pie-chart');
if (!ctx) return;
const isDark = document.documentElement.classList.contains('dark');
if (categoryChart) {
categoryChart.destroy();
}
if (categories.length === 0) {
categoryChart = null;
document.getElementById('category-legend').innerHTML = '<p class="col-span-2 text-center text-text-muted dark:text-[#92adc9]">No data available</p>';
return;
}
categoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: categories.map(c => c.name),
datasets: [{
data: categories.map(c => c.amount),
backgroundColor: categories.map(c => c.color),
borderWidth: 2,
borderColor: isDark ? '#1a2632' : '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
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,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = formatCurrency(context.parsed, 'USD');
const percentage = categories[context.dataIndex].percentage;
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
// Update legend
const legendHTML = categories.slice(0, 6).map(cat => `
<div class="flex items-center gap-2">
<span class="size-3 rounded-full" style="background-color: ${cat.color}"></span>
<span class="text-text-muted dark:text-[#92adc9] flex-1 truncate">${cat.name}</span>
<span class="font-semibold text-text-main dark:text-white">${cat.percentage}%</span>
</div>
`).join('');
document.getElementById('category-legend').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: '#3b82f6',
borderRadius: 6,
hoverBackgroundColor: '#2563eb'
}]
},
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, 'USD');
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: textColor
}
},
y: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
callback: function(value) {
return '$' + value.toFixed(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();
}
}
// 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();
});

View file

@ -0,0 +1,265 @@
// 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;
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;
}
try {
const response = await fetch('/api/settings/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
email,
language,
currency
})
});
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;
}

View file

@ -0,0 +1,287 @@
// Transactions page JavaScript
let currentPage = 1;
let filters = {
category_id: '',
start_date: '',
end_date: '',
search: ''
};
// 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) {
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">No transactions found</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 ? 'Completed' : '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(', ') : '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> ${tx.currency}</span>
</div>
</td>
<td class="p-5 text-right">
<span class="text-text-main dark:text-white font-semibold">${formatCurrency(tx.amount, tx.currency)}</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="window.open('${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="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="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="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;
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>
Previous
</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' : ''}
>
Next
<span class="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
`;
container.innerHTML = html;
}
// Change page
function changePage(page) {
currentPage = page;
loadTransactions();
}
// Delete transaction
async function deleteTransaction(id) {
if (!confirm('Are you sure you want to delete this transaction?')) {
return;
}
try {
await apiCall(`/api/expenses/${id}`, { method: 'DELETE' });
showToast('Transaction deleted', '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');
select.innerHTML = '<option value="">Category</option>' +
data.categories.map(cat => `<option value="${cat.id}">${cat.name}</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
});
showToast(`Imported ${result.imported} transactions`, '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
});
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadTransactions();
loadCategoriesFilter();
});