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

173
app/static/js/admin.js Normal file
View 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);
}
}

198
app/static/js/app.js Normal file
View file

@ -0,0 +1,198 @@
// 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
let errorData;
try {
errorData = await response.json();
} catch (jsonError) {
showToast(window.getTranslation('common.error', 'An error occurred. Please try again.'), 'error');
throw new Error(`HTTP error! status: ${response.status}`);
}
const errorMsg = errorData.message || window.getTranslation('common.error', 'An error occurred. Please try again.');
// Only show toast if it's not a special case that needs custom handling
if (!errorData.requires_reassignment) {
showToast(errorMsg, 'error');
}
// Throw error with data attached for special handling (e.g., category deletion with reassignment)
const error = new Error(`HTTP error! status: ${response.status}`);
Object.assign(error, errorData);
throw error;
}
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;
}
}
// Export apiCall to window for use by other modules
window.apiCall = apiCall;
// 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');
// Only update if elements exist (not all pages have theme toggle in sidebar)
if (!themeIcon || !themeText) {
return;
}
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');
}
}
});
}
});

316
app/static/js/budget.js Normal file
View file

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

1594
app/static/js/dashboard.js Normal file

File diff suppressed because it is too large Load diff

502
app/static/js/documents.js Normal file
View file

@ -0,0 +1,502 @@
// Documents Page Functionality
let currentPage = 1;
const itemsPerPage = 10;
let searchQuery = '';
let allDocuments = [];
// Initialize documents page
document.addEventListener('DOMContentLoaded', () => {
loadDocuments();
setupEventListeners();
// Check if we need to open a document from search
const docId = sessionStorage.getItem('openDocumentId');
const docType = sessionStorage.getItem('openDocumentType');
const docName = sessionStorage.getItem('openDocumentName');
if (docId && docType && docName) {
// Clear the session storage
sessionStorage.removeItem('openDocumentId');
sessionStorage.removeItem('openDocumentType');
sessionStorage.removeItem('openDocumentName');
// Open the document after a short delay to ensure page is loaded
setTimeout(() => {
viewDocument(parseInt(docId), docType, docName);
}, 500);
}
});
// 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);
}

1209
app/static/js/i18n.js Normal file

File diff suppressed because it is too large Load diff

722
app/static/js/import.js Normal file
View file

@ -0,0 +1,722 @@
/**
* CSV/Bank Statement Import Module for FINA PWA
* Handles file upload, parsing, duplicate detection, and category mapping
*/
class CSVImporter {
constructor() {
this.parsedTransactions = [];
this.duplicates = [];
this.categoryMapping = {};
this.userCategories = [];
this.currentStep = 1;
}
/**
* Initialize the importer
*/
async init() {
await this.loadUserProfile();
await this.loadUserCategories();
this.renderImportUI();
this.setupEventListeners();
}
/**
* Load user profile to get currency
*/
async loadUserProfile() {
try {
const response = await window.apiCall('/api/settings/profile');
window.userCurrency = response.profile?.currency || 'USD';
} catch (error) {
console.error('Failed to load user profile:', error);
window.userCurrency = 'USD';
}
}
/**
* Load user's categories from API
*/
async loadUserCategories() {
try {
const response = await window.apiCall('/api/expenses/categories');
this.userCategories = response.categories || [];
} catch (error) {
console.error('Failed to load categories:', error);
this.userCategories = [];
window.showToast(window.getTranslation('import.errorLoadingCategories', 'Failed to load categories'), 'error');
}
}
/**
* Setup event listeners
*/
setupEventListeners() {
// File input change
const fileInput = document.getElementById('csvFileInput');
if (fileInput) {
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
}
// Drag and drop
const dropZone = document.getElementById('csvDropZone');
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-primary', 'bg-primary/5');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-primary', 'bg-primary/5');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-primary', 'bg-primary/5');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFile(files[0]);
}
});
}
}
/**
* Render the import UI
*/
renderImportUI() {
const container = document.getElementById('importContainer');
if (!container) return;
container.innerHTML = `
<div class="max-w-4xl mx-auto">
<!-- Progress Steps -->
<div class="mb-8">
<div class="flex items-center justify-between">
${this.renderStep(1, 'import.stepUpload', 'Upload CSV')}
${this.renderStep(2, 'import.stepReview', 'Review')}
${this.renderStep(3, 'import.stepMap', 'Map Categories')}
${this.renderStep(4, 'import.stepImport', 'Import')}
</div>
</div>
<!-- Step Content -->
<div id="stepContent" class="bg-white dark:bg-[#0f1921] rounded-xl p-6 border border-border-light dark:border-[#233648]">
${this.renderCurrentStep()}
</div>
</div>
`;
}
/**
* Render a progress step
*/
renderStep(stepNum, translationKey, fallback) {
const isActive = this.currentStep === stepNum;
const isComplete = this.currentStep > stepNum;
return `
<div class="flex items-center ${stepNum < 4 ? 'flex-1' : ''}">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full flex items-center justify-center font-semibold
${isComplete ? 'bg-green-500 text-white' : ''}
${isActive ? 'bg-primary text-white' : ''}
${!isActive && !isComplete ? 'bg-slate-200 dark:bg-[#233648] text-text-muted' : ''}">
${isComplete ? '<span class="material-symbols-outlined text-[20px]">check</span>' : stepNum}
</div>
<span class="ml-2 text-sm font-medium ${isActive ? 'text-primary' : 'text-text-muted dark:text-[#92adc9]'}">
${window.getTranslation(translationKey, fallback)}
</span>
</div>
${stepNum < 4 ? '<div class="flex-1 h-0.5 bg-slate-200 dark:bg-[#233648] mx-4"></div>' : ''}
</div>
`;
}
/**
* Render content for current step
*/
renderCurrentStep() {
switch (this.currentStep) {
case 1:
return this.renderUploadStep();
case 2:
return this.renderReviewStep();
case 3:
return this.renderMappingStep();
case 4:
return this.renderImportStep();
default:
return '';
}
}
/**
* Render upload step
*/
renderUploadStep() {
return `
<div class="text-center">
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.uploadTitle', 'Upload CSV File')}
</h2>
<p class="text-text-muted dark:text-[#92adc9] mb-6">
${window.getTranslation('import.uploadDesc', 'Upload your bank statement or expense CSV file')}
</p>
<!-- Drop Zone -->
<div id="csvDropZone"
class="border-2 border-dashed border-border-light dark:border-[#233648] rounded-xl p-12 cursor-pointer hover:border-primary/50 transition-colors"
onclick="document.getElementById('csvFileInput').click()">
<span class="material-symbols-outlined text-6xl text-text-muted dark:text-[#92adc9] mb-4">cloud_upload</span>
<p class="text-lg font-medium text-text-main dark:text-white mb-2">
${window.getTranslation('import.dragDrop', 'Drag and drop your CSV file here')}
</p>
<p class="text-sm text-text-muted dark:text-[#92adc9] mb-4">
${window.getTranslation('import.orClick', 'or click to browse')}
</p>
<input type="file"
id="csvFileInput"
accept=".csv"
class="hidden">
</div>
<!-- Format Info -->
<div class="mt-8 text-left bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 class="font-semibold text-text-main dark:text-white mb-2 flex items-center">
<span class="material-symbols-outlined text-[20px] mr-2">info</span>
${window.getTranslation('import.supportedFormats', 'Supported Formats')}
</h3>
<ul class="text-sm text-text-muted dark:text-[#92adc9] space-y-1">
<li> ${window.getTranslation('import.formatRequirement1', 'CSV files with Date, Description, and Amount columns')}</li>
<li> ${window.getTranslation('import.formatRequirement2', 'Supports comma, semicolon, or tab delimiters')}</li>
<li> ${window.getTranslation('import.formatRequirement3', 'Date formats: DD/MM/YYYY, YYYY-MM-DD, etc.')}</li>
<li> ${window.getTranslation('import.formatRequirement4', 'Maximum file size: 10MB')}</li>
</ul>
</div>
</div>
`;
}
/**
* Handle file selection
*/
async handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
await this.handleFile(file);
}
}
/**
* Handle file upload and parsing
*/
async handleFile(file) {
// Validate file
if (!file.name.toLowerCase().endsWith('.csv')) {
window.showToast(window.getTranslation('import.errorInvalidFile', 'Please select a CSV file'), 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
window.showToast(window.getTranslation('import.errorFileTooLarge', 'File too large. Maximum 10MB'), 'error');
return;
}
// Show loading
const stepContent = document.getElementById('stepContent');
stepContent.innerHTML = `
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p class="text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.parsing', 'Parsing CSV file...')}</p>
</div>
`;
try {
// Upload and parse
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/import/parse-csv', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to parse CSV');
}
this.parsedTransactions = result.transactions;
// Check for duplicates
await this.checkDuplicates();
// Move to review step
this.currentStep = 2;
this.renderImportUI();
} catch (error) {
console.error('Failed to parse CSV:', error);
window.showToast(error.message || window.getTranslation('import.errorParsing', 'Failed to parse CSV file'), 'error');
this.currentStep = 1;
this.renderImportUI();
}
}
/**
* Check for duplicate transactions
*/
async checkDuplicates() {
try {
const response = await fetch('/api/import/detect-duplicates', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactions: this.parsedTransactions
})
});
const result = await response.json();
if (result.success) {
this.duplicates = result.duplicates || [];
// Mark transactions as duplicates
this.parsedTransactions.forEach((trans, idx) => {
const isDuplicate = this.duplicates.some(d =>
d.transaction.date === trans.date &&
d.transaction.amount === trans.amount &&
d.transaction.description === trans.description
);
this.parsedTransactions[idx].is_duplicate = isDuplicate;
});
}
} catch (error) {
console.error('Failed to check duplicates:', error);
}
}
/**
* Render review step
*/
renderReviewStep() {
const duplicateCount = this.parsedTransactions.filter(t => t.is_duplicate).length;
const newCount = this.parsedTransactions.length - duplicateCount;
return `
<div>
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.reviewTitle', 'Review Transactions')}
</h2>
<!-- Summary -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">${this.parsedTransactions.length}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.totalFound', 'Total Found')}</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${newCount}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.newTransactions', 'New')}</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">${duplicateCount}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.duplicates', 'Duplicates')}</div>
</div>
</div>
<!-- Transactions List -->
<div class="mb-6 max-h-96 overflow-y-auto">
${this.parsedTransactions.map((trans, idx) => this.renderTransactionRow(trans, idx)).join('')}
</div>
<!-- Actions -->
<div class="flex justify-between">
<button onclick="csvImporter.goToStep(1)"
class="px-6 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
${window.getTranslation('common.back', 'Back')}
</button>
<button onclick="csvImporter.goToStep(3)"
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">
${window.getTranslation('import.nextMapCategories', 'Next: Map Categories')}
</button>
</div>
</div>
`;
}
/**
* Render a transaction row
*/
renderTransactionRow(trans, idx) {
const isDuplicate = trans.is_duplicate;
return `
<div class="flex items-center justify-between p-3 border-b border-border-light dark:border-[#233648] ${isDuplicate ? 'bg-yellow-50/50 dark:bg-yellow-900/10' : ''}">
<div class="flex items-center gap-4 flex-1">
<input type="checkbox"
id="trans_${idx}"
${isDuplicate ? '' : 'checked'}
onchange="csvImporter.toggleTransaction(${idx})"
class="w-5 h-5 rounded border-border-light dark:border-[#233648]">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-text-main dark:text-white">${trans.description}</span>
${isDuplicate ? '<span class="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 text-xs rounded-full">' + window.getTranslation('import.duplicate', 'Duplicate') + '</span>' : ''}
</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${trans.date}</div>
</div>
</div>
<div class="font-semibold text-text-main dark:text-white">${window.formatCurrency(trans.amount, trans.currency || window.userCurrency || 'GBP')}</div>
</div>
`;
}
/**
* Check for missing categories and offer to create them
*/
async checkAndCreateCategories() {
const selectedTransactions = this.parsedTransactions.filter(t => {
const checkbox = document.getElementById(`trans_${this.parsedTransactions.indexOf(t)}`);
return !checkbox || checkbox.checked;
});
// Get unique bank categories (skip generic payment types)
const paymentTypes = ['pot transfer', 'card payment', 'direct debit', 'monzo_paid',
'faster payment', 'bacs (direct credit)', 'bacs', 'standing order'];
const bankCategories = new Set();
selectedTransactions.forEach(trans => {
if (trans.bank_category && trans.bank_category.trim()) {
const catLower = trans.bank_category.trim().toLowerCase();
// Skip if it's a generic payment type
if (!paymentTypes.includes(catLower)) {
bankCategories.add(trans.bank_category.trim());
}
}
});
if (bankCategories.size === 0) {
return; // No bank categories to create
}
// Find which categories don't exist
const existingCatNames = new Set(this.userCategories.map(c => c.name.toLowerCase()));
const missingCategories = Array.from(bankCategories).filter(
cat => !existingCatNames.has(cat.toLowerCase())
);
if (missingCategories.length > 0) {
// Show confirmation dialog
const confirmCreate = confirm(
window.getTranslation(
'import.createMissingCategories',
`Found ${missingCategories.length} new categories from your CSV:\n\n${missingCategories.join('\n')}\n\nWould you like to create these categories automatically?`
)
);
if (confirmCreate) {
try {
const response = await window.apiCall('/api/import/create-categories', {
method: 'POST',
body: JSON.stringify({
bank_categories: missingCategories
})
});
if (response.success) {
window.showToast(
window.getTranslation(
'import.categoriesCreated',
`Created ${response.created.length} new categories`
),
'success'
);
// Update category mapping with new categories
Object.assign(this.categoryMapping, response.mapping);
// Reload categories
await this.loadUserCategories();
this.renderImportUI();
this.setupEventListeners();
}
} catch (error) {
console.error('Failed to create categories:', error);
window.showToast(
window.getTranslation('import.errorCreatingCategories', 'Failed to create categories'),
'error'
);
}
}
}
}
/**
* Toggle transaction selection
*/
toggleTransaction(idx) {
const checkbox = document.getElementById(`trans_${idx}`);
this.parsedTransactions[idx].selected = checkbox.checked;
}
/**
* Render mapping step
*/
renderMappingStep() {
const selectedTransactions = this.parsedTransactions.filter(t => {
const checkbox = document.getElementById(`trans_${this.parsedTransactions.indexOf(t)}`);
return !checkbox || checkbox.checked;
});
// Get unique bank categories or descriptions for mapping (skip payment types)
const paymentTypes = ['pot transfer', 'card payment', 'direct debit', 'monzo_paid',
'faster payment', 'bacs (direct credit)', 'bacs', 'standing order'];
const needsMapping = new Set();
selectedTransactions.forEach(trans => {
if (trans.bank_category) {
const catLower = trans.bank_category.toLowerCase();
// Skip generic payment types
if (!paymentTypes.includes(catLower)) {
needsMapping.add(trans.bank_category);
}
}
});
return `
<div>
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.mapCategories', 'Map Categories')}
</h2>
<p class="text-text-muted dark:text-[#92adc9] mb-6">
${window.getTranslation('import.mapCategoriesDesc', 'Assign categories to your transactions')}
</p>
${needsMapping.size > 0 ? `
<div class="mb-6">
<h3 class="font-semibold mb-4 text-text-main dark:text-white">${window.getTranslation('import.bankCategoryMapping', 'Bank Category Mapping')}</h3>
${Array.from(needsMapping).map(bankCat => this.renderCategoryMapping(bankCat)).join('')}
</div>
` : ''}
<div class="mb-6">
<h3 class="font-semibold mb-4 text-text-main dark:text-white">${window.getTranslation('import.defaultCategory', 'Default Category')}</h3>
<select id="defaultCategory" class="w-full px-4 py-2 border border-border-light dark:border-[#233648] rounded-lg bg-white dark:bg-[#111a22] text-text-main dark:text-white">
${this.userCategories.map(cat => `
<option value="${cat.id}">${cat.name}</option>
`).join('')}
</select>
<p class="text-xs text-text-muted dark:text-[#92adc9] mt-2">
${window.getTranslation('import.defaultCategoryDesc', 'Used for transactions without bank category')}
</p>
</div>
<!-- Actions -->
<div class="flex justify-between">
<button onclick="csvImporter.goToStep(2)"
class="px-6 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
${window.getTranslation('common.back', 'Back')}
</button>
<button onclick="csvImporter.startImport()"
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 flex items-center gap-2">
<span class="material-symbols-outlined text-[20px]">download</span>
${window.getTranslation('import.startImport', 'Import Transactions')}
</button>
</div>
</div>
`;
}
/**
* Render category mapping dropdown
*/
renderCategoryMapping(bankCategory) {
return `
<div class="mb-4 flex items-center gap-4">
<div class="flex-1">
<div class="font-medium text-text-main dark:text-white mb-1">${bankCategory}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.bankCategory', 'Bank Category')}</div>
</div>
<span class="text-text-muted"></span>
<select id="mapping_${bankCategory.replace(/[^a-zA-Z0-9]/g, '_')}"
onchange="csvImporter.setMapping('${bankCategory}', this.value)"
class="flex-1 px-4 py-2 border border-border-light dark:border-[#233648] rounded-lg bg-white dark:bg-[#111a22] text-text-main dark:text-white">
${this.userCategories.map(cat => `
<option value="${cat.id}">${cat.name}</option>
`).join('')}
</select>
</div>
`;
}
/**
* Set category mapping
*/
setMapping(bankCategory, categoryId) {
this.categoryMapping[bankCategory] = parseInt(categoryId);
}
/**
* Start import process
*/
async startImport() {
const selectedTransactions = this.parsedTransactions.filter((t, idx) => {
const checkbox = document.getElementById(`trans_${idx}`);
return !checkbox || checkbox.checked;
});
if (selectedTransactions.length === 0) {
window.showToast(window.getTranslation('import.noTransactionsSelected', 'No transactions selected'), 'error');
return;
}
// Show loading
this.currentStep = 4;
this.renderImportUI();
try {
const response = await window.apiCall('/api/import/import', {
method: 'POST',
body: JSON.stringify({
transactions: selectedTransactions,
category_mapping: this.categoryMapping,
skip_duplicates: true
})
});
if (response.success) {
this.renderImportComplete(response);
} else {
throw new Error(response.error || 'Import failed');
}
} catch (error) {
console.error('Import failed:', error);
window.showToast(error.message || window.getTranslation('import.errorImporting', 'Failed to import transactions'), 'error');
this.goToStep(3);
}
}
/**
* Render import complete step
*/
renderImportStep() {
return `
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p class="text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.importing', 'Importing transactions...')}</p>
</div>
`;
}
/**
* Render import complete
*/
renderImportComplete(result) {
const stepContent = document.getElementById('stepContent');
const hasErrors = result.errors && result.errors.length > 0;
stepContent.innerHTML = `
<div class="text-center">
<div class="inline-block w-20 h-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
<span class="material-symbols-outlined text-5xl text-green-600 dark:text-green-400">check_circle</span>
</div>
<h2 class="text-2xl font-bold mb-4 text-text-main dark:text-white">
${window.getTranslation('import.importComplete', 'Import Complete!')}
</h2>
<div class="grid grid-cols-3 gap-4 mb-8 max-w-2xl mx-auto">
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${result.imported_count}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.imported', 'Imported')}</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">${result.skipped_count}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.skipped', 'Skipped')}</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600 dark:text-red-400">${result.error_count}</div>
<div class="text-sm text-text-muted dark:text-[#92adc9]">${window.getTranslation('import.errors', 'Errors')}</div>
</div>
</div>
${hasErrors ? `
<div class="mb-6 text-left max-w-2xl mx-auto">
<details class="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/20 rounded-lg p-4">
<summary class="cursor-pointer font-semibold text-red-600 dark:text-red-400 mb-2">
${window.getTranslation('import.viewErrors', 'View Error Details')} (${result.error_count})
</summary>
<div class="mt-4 space-y-2 max-h-64 overflow-y-auto">
${result.errors.slice(0, 20).map((err, idx) => `
<div class="text-sm p-3 bg-white dark:bg-[#111a22] border border-red-100 dark:border-red-900/30 rounded">
<div class="font-medium text-text-main dark:text-white mb-1">
${err.transaction?.description || 'Transaction ' + (idx + 1)}
</div>
<div class="text-red-600 dark:text-red-400 text-xs">${err.error}</div>
</div>
`).join('')}
${result.errors.length > 20 ? `
<div class="text-sm text-text-muted dark:text-[#92adc9] italic p-2">
... and ${result.errors.length - 20} more errors
</div>
` : ''}
</div>
</details>
</div>
` : ''}
<div class="flex gap-4 justify-center">
<button onclick="window.location.href='/transactions'"
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">
${window.getTranslation('import.viewTransactions', 'View Transactions')}
</button>
<button onclick="csvImporter.reset()"
class="px-6 py-2 border border-border-light dark:border-[#233648] rounded-lg text-text-main dark:text-white hover:bg-slate-100 dark:hover:bg-[#111a22]">
${window.getTranslation('import.importAnother', 'Import Another File')}
</button>
</div>
</div>
`;
}
/**
* Go to a specific step
*/
async goToStep(step) {
this.currentStep = step;
// If going to mapping step, check for missing categories
if (step === 3) {
await this.checkAndCreateCategories();
}
this.renderImportUI();
this.setupEventListeners();
}
/**
* Reset importer
*/
reset() {
this.parsedTransactions = [];
this.duplicates = [];
this.categoryMapping = {};
this.currentStep = 1;
this.renderImportUI();
}
}
// Create global instance
window.csvImporter = new CSVImporter();
// Initialize on import page
if (window.location.pathname === '/import' || window.location.pathname.includes('import')) {
document.addEventListener('DOMContentLoaded', () => {
window.csvImporter.init();
});
}

425
app/static/js/income.js Normal file
View file

@ -0,0 +1,425 @@
// Income Management JavaScript
let incomeData = [];
let incomeSources = [];
let currentIncomeId = null;
// Helper function for notifications
function showNotification(message, type = 'success') {
if (typeof showToast === 'function') {
showToast(message, type);
} else {
console.log(`${type.toUpperCase()}: ${message}`);
}
}
// Load user currency from profile
async function loadUserCurrency() {
try {
const profile = await apiCall('/api/settings/profile');
window.userCurrency = profile.profile.currency || 'GBP';
} catch (error) {
console.error('Failed to load user currency:', error);
// Fallback to GBP if API fails
window.userCurrency = 'GBP';
}
}
// Load income data
async function loadIncome() {
try {
console.log('Loading income data...');
const response = await apiCall('/api/income/');
console.log('Income API response:', response);
console.log('Response has income?', response.income);
console.log('Income array:', response.income);
if (response.income) {
incomeData = response.income;
console.log('Income data loaded:', incomeData.length, 'entries');
console.log('Full income data:', JSON.stringify(incomeData, null, 2));
renderIncomeTable();
} else {
console.warn('No income data in response');
incomeData = [];
renderIncomeTable();
}
} catch (error) {
console.error('Error loading income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Load income sources
async function loadIncomeSources() {
try {
const response = await apiCall('/api/income/sources');
if (response.sources) {
incomeSources = response.sources;
renderIncomeSourceOptions();
}
} catch (error) {
console.error('Error loading income sources:', error);
}
}
// Render income source options in select
function renderIncomeSourceOptions() {
const selects = document.querySelectorAll('.income-source-select');
selects.forEach(select => {
select.innerHTML = '<option value="">' + window.getTranslation('form.selectSource', 'Select source...') + '</option>';
incomeSources.forEach(source => {
select.innerHTML += `<option value="${source.value}">${source.label}</option>`;
});
});
}
// Render income table
function renderIncomeTable() {
console.log('Rendering income table with', incomeData.length, 'entries');
const tbody = document.getElementById('income-table-body');
if (!tbody) {
console.error('Income table body not found!');
return;
}
if (incomeData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<span class="material-symbols-outlined text-6xl text-text-muted dark:text-[#92adc9] mb-4 block">payments</span>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="income.noIncome">${window.getTranslation('income.noIncome', 'No income entries yet')}</p>
<p class="text-sm text-text-muted dark:text-[#92adc9] mt-2" data-translate="income.addFirst">${window.getTranslation('income.addFirst', 'Add your first income entry')}</p>
</td>
</tr>
`;
return;
}
tbody.innerHTML = incomeData.map(income => {
const date = new Date(income.date);
const formattedDate = formatDate(income.date);
const source = incomeSources.find(s => s.value === income.source);
const sourceLabel = source ? source.label : income.source;
const sourceIcon = source ? source.icon : 'category';
// Check if this is recurring income
const isRecurring = income.is_recurring;
const nextDueDate = income.next_due_date ? formatDate(income.next_due_date) : null;
const isActive = income.is_active;
const autoCreate = income.auto_create;
// Build recurring info badge
let recurringBadge = '';
if (isRecurring && autoCreate) {
const statusColor = isActive ? 'green' : 'gray';
const statusIcon = isActive ? 'check_circle' : 'pause_circle';
recurringBadge = `
<div class="flex items-center gap-1 text-xs text-${statusColor}-600 dark:text-${statusColor}-400">
<span class="material-symbols-outlined text-[14px]">${statusIcon}</span>
<span>${income.frequency}</span>
${nextDueDate ? `<span class="text-text-muted dark:text-[#92adc9]">• Next: ${nextDueDate}</span>` : ''}
</div>
`;
}
// Build action buttons
let actionButtons = `
<button onclick="editIncome(${income.id})" class="p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors">
<span class="material-symbols-outlined text-[20px]">edit</span>
</button>
`;
if (isRecurring && autoCreate) {
actionButtons += `
<button onclick="toggleRecurringIncome(${income.id})" class="p-2 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-colors" title="${isActive ? 'Pause' : 'Activate'}">
<span class="material-symbols-outlined text-[20px]">${isActive ? 'pause' : 'play_arrow'}</span>
</button>
<button onclick="createIncomeNow(${income.id})" class="p-2 text-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-colors" title="Create Now">
<span class="material-symbols-outlined text-[20px]">add_circle</span>
</button>
`;
}
actionButtons += `
<button onclick="deleteIncome(${income.id})" class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
`;
return `
<tr class="border-b border-border-light dark:border-[#233648] hover:bg-slate-50 dark:hover:bg-[#111a22] transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<span class="material-symbols-outlined text-green-500 text-[20px]">${sourceIcon}</span>
</div>
<div>
<p class="font-medium text-text-main dark:text-white">${income.description}</p>
<p class="text-sm text-text-muted dark:text-[#92adc9]">${sourceLabel}</p>
${recurringBadge}
</div>
</div>
</td>
<td class="px-6 py-4 text-text-main dark:text-white">${formattedDate}</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-400">
${sourceLabel}
</span>
</td>
<td class="px-6 py-4 text-right">
<span class="font-semibold text-green-600 dark:text-green-400">
+${formatCurrency(income.amount, income.currency)}
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
${actionButtons}
</div>
</td>
</tr>
`;
}).join('');
console.log('Income table rendered successfully');
}
// Open income modal
function openIncomeModal() {
const modal = document.getElementById('income-modal');
const form = document.getElementById('income-form');
const title = document.getElementById('income-modal-title');
currentIncomeId = null;
form.reset();
title.textContent = window.getTranslation('income.add', 'Add Income');
modal.classList.remove('hidden');
// Set today's date as default
const dateInput = document.getElementById('income-date');
if (dateInput) {
dateInput.valueAsDate = new Date();
}
}
// Close income modal
function closeIncomeModal() {
const modal = document.getElementById('income-modal');
modal.classList.add('hidden');
currentIncomeId = null;
}
// Edit income
function editIncome(id) {
const income = incomeData.find(i => i.id === id);
if (!income) return;
currentIncomeId = id;
const modal = document.getElementById('income-modal');
const form = document.getElementById('income-form');
const title = document.getElementById('income-modal-title');
title.textContent = window.getTranslation('income.edit', 'Edit Income');
document.getElementById('income-amount').value = income.amount;
document.getElementById('income-source').value = income.source;
document.getElementById('income-description').value = income.description;
document.getElementById('income-date').value = income.date.split('T')[0];
document.getElementById('income-tags').value = income.tags.join(', ');
document.getElementById('income-frequency').value = income.frequency || 'once';
// Show/hide custom frequency based on frequency value
const customContainer = document.getElementById('custom-frequency-container');
if (income.frequency === 'custom') {
customContainer.classList.remove('hidden');
document.getElementById('income-custom-days').value = income.custom_days || '';
} else {
customContainer.classList.add('hidden');
}
// Set auto_create checkbox
const autoCreateCheckbox = document.getElementById('income-auto-create');
if (autoCreateCheckbox) {
autoCreateCheckbox.checked = income.auto_create || false;
}
modal.classList.remove('hidden');
}
// Save income
async function saveIncome(event) {
event.preventDefault();
console.log('Saving income...');
const amount = document.getElementById('income-amount').value;
const source = document.getElementById('income-source').value;
const description = document.getElementById('income-description').value;
const date = document.getElementById('income-date').value;
const tagsInput = document.getElementById('income-tags').value;
const frequency = document.getElementById('income-frequency').value;
const customDays = document.getElementById('income-custom-days').value;
const autoCreate = document.getElementById('income-auto-create')?.checked || false;
if (!amount || !source || !description) {
showNotification(window.getTranslation('common.missingFields', 'Missing required fields'), 'error');
return;
}
// Validate custom frequency
if (frequency === 'custom' && (!customDays || customDays < 1)) {
showNotification(window.getTranslation('income.customDaysRequired', 'Please enter a valid number of days for custom frequency'), 'error');
return;
}
const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t) : [];
const data = {
amount: parseFloat(amount),
source: source,
description: description,
date: date,
tags: tags,
currency: window.userCurrency,
frequency: frequency,
custom_days: frequency === 'custom' ? parseInt(customDays) : null,
auto_create: autoCreate
};
console.log('Income data to save:', data);
try {
let response;
if (currentIncomeId) {
console.log('Updating income:', currentIncomeId);
response = await apiCall(`/api/income/${currentIncomeId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showNotification(window.getTranslation('income.updated', 'Income updated successfully'), 'success');
} else {
console.log('Creating new income');
response = await apiCall('/api/income/', {
method: 'POST',
body: JSON.stringify(data)
});
console.log('Income created response:', response);
showNotification(window.getTranslation('income.created', 'Income added successfully'), 'success');
}
closeIncomeModal();
console.log('Reloading income list...');
await loadIncome();
// Reload dashboard if on dashboard page
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
} catch (error) {
console.error('Error saving income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Delete income
async function deleteIncome(id) {
if (!confirm(window.getTranslation('income.deleteConfirm', 'Are you sure you want to delete this income entry?'))) {
return;
}
try {
await apiCall(`/api/income/${id}`, {
method: 'DELETE'
});
showNotification(window.getTranslation('income.deleted', 'Income deleted successfully'), 'success');
loadIncome();
// Reload dashboard if on dashboard page
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
} catch (error) {
console.error('Error deleting income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Toggle recurring income active status
async function toggleRecurringIncome(id) {
try {
const response = await apiCall(`/api/income/${id}/toggle`, {
method: 'PUT'
});
if (response.success) {
showNotification(response.message, 'success');
loadIncome();
}
} catch (error) {
console.error('Error toggling recurring income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Create income now from recurring income
async function createIncomeNow(id) {
if (!confirm(window.getTranslation('income.createNowConfirm', 'Create an income entry now from this recurring income?'))) {
return;
}
try {
const response = await apiCall(`/api/income/${id}/create-now`, {
method: 'POST'
});
if (response.success) {
showNotification(response.message, 'success');
loadIncome();
// Reload dashboard if on dashboard page
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
}
} catch (error) {
console.error('Error creating income:', error);
showNotification(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Initialize income page
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('income-table-body')) {
loadUserCurrency(); // Load currency first
loadIncome();
loadIncomeSources();
// Setup form submit
const form = document.getElementById('income-form');
if (form) {
form.addEventListener('submit', saveIncome);
}
// Setup frequency change handler
const frequencySelect = document.getElementById('income-frequency');
if (frequencySelect) {
frequencySelect.addEventListener('change', (e) => {
const customContainer = document.getElementById('custom-frequency-container');
if (customContainer) {
if (e.target.value === 'custom') {
customContainer.classList.remove('hidden');
} else {
customContainer.classList.add('hidden');
}
}
});
}
}
});
// Make functions global
window.openIncomeModal = openIncomeModal;
window.closeIncomeModal = closeIncomeModal;
window.editIncome = editIncome;
window.deleteIncome = deleteIncome;
window.saveIncome = saveIncome;
window.toggleRecurringIncome = toggleRecurringIncome;
window.createIncomeNow = createIncomeNow;

View file

@ -0,0 +1,264 @@
/**
* Budget Notifications Module
* Handles PWA push notifications for budget alerts
*/
class BudgetNotifications {
constructor() {
this.notificationPermission = 'default';
this.checkPermission();
}
/**
* Check current notification permission status
*/
checkPermission() {
if ('Notification' in window) {
this.notificationPermission = Notification.permission;
}
}
/**
* Request notification permission from user
*/
async requestPermission() {
if (!('Notification' in window)) {
console.warn('This browser does not support notifications');
return false;
}
if (this.notificationPermission === 'granted') {
return true;
}
try {
const permission = await Notification.requestPermission();
this.notificationPermission = permission;
if (permission === 'granted') {
// Store permission preference
localStorage.setItem('budgetNotificationsEnabled', 'true');
return true;
}
return false;
} catch (error) {
console.error('Error requesting notification permission:', error);
return false;
}
}
/**
* Show a budget alert notification
*/
async showBudgetAlert(alert) {
if (this.notificationPermission !== 'granted') {
return;
}
try {
const icon = '/static/icons/icon-192x192.png';
const badge = '/static/icons/icon-72x72.png';
let title = '';
let body = '';
let tag = `budget-alert-${alert.type}`;
switch (alert.type) {
case 'category':
title = window.getTranslation('budget.categoryAlert');
body = window.getTranslation('budget.categoryAlertMessage')
.replace('{category}', alert.category_name)
.replace('{percentage}', alert.percentage.toFixed(0));
tag = `budget-category-${alert.category_id}`;
break;
case 'overall':
title = window.getTranslation('budget.overallAlert');
body = window.getTranslation('budget.overallAlertMessage')
.replace('{percentage}', alert.percentage.toFixed(0));
break;
case 'exceeded':
title = window.getTranslation('budget.exceededAlert');
body = window.getTranslation('budget.exceededAlertMessage')
.replace('{category}', alert.category_name);
tag = `budget-exceeded-${alert.category_id}`;
break;
}
const options = {
body: body,
icon: icon,
badge: badge,
tag: tag, // Prevents duplicate notifications
renotify: true,
requireInteraction: alert.level === 'danger' || alert.level === 'exceeded',
data: {
url: alert.type === 'overall' ? '/dashboard' : '/transactions',
categoryId: alert.category_id
}
};
// Use service worker for better notification handling
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready.then(registration => {
registration.showNotification(title, options);
});
} else {
// Fallback to regular notification
const notification = new Notification(title, options);
notification.onclick = function(event) {
event.preventDefault();
window.focus();
if (options.data.url) {
window.location.href = options.data.url;
}
notification.close();
};
}
} catch (error) {
console.error('Error showing notification:', error);
}
}
/**
* Show weekly spending summary notification
*/
async showWeeklySummary(summary) {
if (this.notificationPermission !== 'granted') {
return;
}
try {
const icon = '/static/icons/icon-192x192.png';
const badge = '/static/icons/icon-72x72.png';
const title = window.getTranslation('budget.weeklySummary');
const spent = window.formatCurrency(summary.current_week_spent);
const change = summary.percentage_change > 0 ? '+' : '';
const changeText = `${change}${summary.percentage_change.toFixed(0)}%`;
const body = window.getTranslation('budget.weeklySummaryMessage')
.replace('{spent}', spent)
.replace('{change}', changeText)
.replace('{category}', summary.top_category);
const options = {
body: body,
icon: icon,
badge: badge,
tag: 'weekly-summary',
data: {
url: '/reports'
}
};
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready.then(registration => {
registration.showNotification(title, options);
});
} else {
const notification = new Notification(title, options);
notification.onclick = function(event) {
event.preventDefault();
window.focus();
window.location.href = '/reports';
notification.close();
};
}
} catch (error) {
console.error('Error showing weekly summary:', error);
}
}
/**
* Check if notifications are enabled in settings
*/
isEnabled() {
return localStorage.getItem('budgetNotificationsEnabled') === 'true';
}
/**
* Enable/disable budget notifications
*/
async setEnabled(enabled) {
if (enabled) {
const granted = await this.requestPermission();
if (granted) {
localStorage.setItem('budgetNotificationsEnabled', 'true');
return true;
}
return false;
} else {
localStorage.setItem('budgetNotificationsEnabled', 'false');
return true;
}
}
}
// Create global instance
window.budgetNotifications = new BudgetNotifications();
/**
* Check budget status and show alerts if needed
*/
async function checkBudgetAlerts() {
if (!window.budgetNotifications.isEnabled()) {
return;
}
try {
const data = await window.apiCall('/api/budget/status', 'GET');
if (data.active_alerts && data.active_alerts.length > 0) {
// Show only the most severe alert to avoid spam
const mostSevereAlert = data.active_alerts[0];
await window.budgetNotifications.showBudgetAlert(mostSevereAlert);
}
} catch (error) {
console.error('Error checking budget alerts:', error);
}
}
/**
* Check if it's time to show weekly summary
* Shows on Monday morning if not shown this week
*/
async function checkWeeklySummary() {
if (!window.budgetNotifications.isEnabled()) {
return;
}
const lastShown = localStorage.getItem('lastWeeklySummaryShown');
const now = new Date();
const dayOfWeek = now.getDay(); // 0 = Sunday, 1 = Monday
// Show on Monday (1) between 9 AM and 11 AM
if (dayOfWeek === 1 && now.getHours() >= 9 && now.getHours() < 11) {
const today = now.toDateString();
if (lastShown !== today) {
try {
const data = await window.apiCall('/api/budget/weekly-summary', 'GET');
await window.budgetNotifications.showWeeklySummary(data);
localStorage.setItem('lastWeeklySummaryShown', today);
} catch (error) {
console.error('Error showing weekly summary:', error);
}
}
}
}
// Check budget alerts every 30 minutes
if (window.budgetNotifications.isEnabled()) {
setInterval(checkBudgetAlerts, 30 * 60 * 1000);
// Check immediately on load
setTimeout(checkBudgetAlerts, 5000);
}
// Check weekly summary once per hour
setInterval(checkWeeklySummary, 60 * 60 * 1000);
setTimeout(checkWeeklySummary, 10000);

54
app/static/js/pwa.js Normal file
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');
});

499
app/static/js/recurring.js Normal file
View file

@ -0,0 +1,499 @@
// Recurring expenses page JavaScript
let currentRecurring = [];
let detectedSuggestions = [];
// Load user profile to get currency
async function loadUserCurrency() {
try {
const profile = await apiCall('/api/settings/profile');
window.userCurrency = profile.profile.currency || 'GBP';
} catch (error) {
console.error('Failed to load user currency:', error);
window.userCurrency = 'GBP';
}
}
// Load recurring expenses
async function loadRecurringExpenses() {
try {
const data = await apiCall('/api/recurring/');
currentRecurring = data.recurring_expenses || [];
displayRecurringExpenses(currentRecurring);
} catch (error) {
console.error('Failed to load recurring expenses:', error);
showToast(window.getTranslation('recurring.errorLoading', 'Failed to load recurring expenses'), 'error');
}
}
// Display recurring expenses
function displayRecurringExpenses(recurring) {
const container = document.getElementById('recurring-list');
if (!recurring || recurring.length === 0) {
const noRecurringText = window.getTranslation('recurring.noRecurring', 'No recurring expenses yet');
const addFirstText = window.getTranslation('recurring.addFirst', 'Add your first recurring expense or detect patterns from existing expenses');
container.innerHTML = `
<div class="p-12 text-center">
<span class="material-symbols-outlined text-6xl text-[#92adc9] mb-4 block">repeat</span>
<p class="text-[#92adc9] text-lg mb-2">${noRecurringText}</p>
<p class="text-[#92adc9] text-sm">${addFirstText}</p>
</div>
`;
return;
}
// Group by active status
const active = recurring.filter(r => r.is_active);
const inactive = recurring.filter(r => !r.is_active);
let html = '';
if (active.length > 0) {
html += '<div class="mb-6"><h3 class="text-lg font-semibold text-text-main dark:text-white mb-4">' +
window.getTranslation('recurring.active', 'Active Recurring Expenses') + '</h3>';
html += '<div class="space-y-3">' + active.map(r => renderRecurringCard(r)).join('') + '</div></div>';
}
if (inactive.length > 0) {
html += '<div><h3 class="text-lg font-semibold text-text-muted dark:text-[#92adc9] mb-4">' +
window.getTranslation('recurring.inactive', 'Inactive') + '</h3>';
html += '<div class="space-y-3 opacity-60">' + inactive.map(r => renderRecurringCard(r)).join('') + '</div></div>';
}
container.innerHTML = html;
}
// Render individual recurring expense card
function renderRecurringCard(recurring) {
const nextDue = new Date(recurring.next_due_date);
const today = new Date();
const daysUntil = Math.ceil((nextDue - today) / (1000 * 60 * 60 * 24));
let dueDateClass = 'text-text-muted dark:text-[#92adc9]';
let dueDateText = '';
if (daysUntil < 0) {
dueDateClass = 'text-red-400';
dueDateText = window.getTranslation('recurring.overdue', 'Overdue');
} else if (daysUntil === 0) {
dueDateClass = 'text-orange-400';
dueDateText = window.getTranslation('recurring.dueToday', 'Due today');
} else if (daysUntil <= 7) {
dueDateClass = 'text-yellow-400';
dueDateText = window.getTranslation('recurring.dueIn', 'Due in') + ` ${daysUntil} ` +
(daysUntil === 1 ? window.getTranslation('recurring.day', 'day') : window.getTranslation('recurring.days', 'days'));
} else {
dueDateText = nextDue.toLocaleDateString();
}
const frequencyText = window.getTranslation(`recurring.frequency.${recurring.frequency}`, recurring.frequency);
const autoCreateBadge = recurring.auto_create ?
`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-500/10 text-green-400 border border-green-500/20">
<span class="material-symbols-outlined text-[14px]">check_circle</span>
${window.getTranslation('recurring.autoCreate', 'Auto-create')}
</span>` : '';
const detectedBadge = recurring.detected ?
`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span class="material-symbols-outlined text-[14px]">auto_awesome</span>
${window.getTranslation('recurring.detected', 'Auto-detected')} ${Math.round(recurring.confidence_score)}%
</span>` : '';
return `
<div class="bg-white dark:bg-[#0f1419] border border-gray-200 dark:border-white/10 rounded-xl p-5 hover:shadow-lg transition-shadow">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 flex-1">
<div class="size-12 rounded-full flex items-center justify-center shrink-0" style="background: ${recurring.category_color}20;">
<span class="material-symbols-outlined text-[24px]" style="color: ${recurring.category_color};">repeat</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-text-main dark:text-white font-semibold truncate">${recurring.name}</h4>
${autoCreateBadge}
${detectedBadge}
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-text-muted dark:text-[#92adc9] mb-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs" style="background: ${recurring.category_color}20; color: ${recurring.category_color};">
${recurring.category_name}
</span>
<span></span>
<span>${frequencyText}</span>
${recurring.notes ? `<span>•</span><span class="truncate">${recurring.notes}</span>` : ''}
</div>
<div class="flex items-center gap-3 text-sm flex-wrap">
<div class="${dueDateClass} font-medium">
<span class="material-symbols-outlined text-[16px] align-middle mr-1">schedule</span>
${dueDateText}
</div>
<div class="text-text-main dark:text-white font-semibold">
${formatCurrency(recurring.amount, window.userCurrency || recurring.currency)}
</div>
${recurring.last_created_date ? `
<div class="text-text-muted dark:text-[#92adc9] text-xs">
<span class="material-symbols-outlined text-[14px] align-middle mr-1">check_circle</span>
${window.getTranslation('recurring.lastCreated', 'Last created')}: ${new Date(recurring.last_created_date).toLocaleDateString()}
</div>
` : ''}
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
${daysUntil <= 7 && recurring.is_active ? `
<button onclick="createExpenseFromRecurring(${recurring.id})"
class="p-2 rounded-lg hover:bg-green-500/10 text-green-400 transition-colors"
title="${window.getTranslation('recurring.createExpense', 'Create expense now')}">
<span class="material-symbols-outlined text-[20px]">add_circle</span>
</button>
` : ''}
<button onclick="editRecurring(${recurring.id})"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-text-muted dark:text-[#92adc9] transition-colors"
title="${window.getTranslation('common.edit', 'Edit')}">
<span class="material-symbols-outlined text-[20px]">edit</span>
</button>
<button onclick="toggleRecurringActive(${recurring.id}, ${!recurring.is_active})"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-text-muted dark:text-[#92adc9] transition-colors"
title="${recurring.is_active ? window.getTranslation('recurring.deactivate', 'Deactivate') : window.getTranslation('recurring.activate', 'Activate')}">
<span class="material-symbols-outlined text-[20px]">${recurring.is_active ? 'pause_circle' : 'play_circle'}</span>
</button>
<button onclick="deleteRecurring(${recurring.id})"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-500/10 text-red-400 transition-colors"
title="${window.getTranslation('common.delete', 'Delete')}">
<span class="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</div>
</div>
`;
}
// Create expense from recurring
async function createExpenseFromRecurring(recurringId) {
try {
const data = await apiCall(`/api/recurring/${recurringId}/create-expense`, {
method: 'POST'
});
showToast(window.getTranslation('recurring.expenseCreated', 'Expense created successfully!'), 'success');
loadRecurringExpenses();
} catch (error) {
console.error('Failed to create expense:', error);
showToast(window.getTranslation('recurring.errorCreating', 'Failed to create expense'), 'error');
}
}
// Toggle recurring active status
async function toggleRecurringActive(recurringId, isActive) {
try {
await apiCall(`/api/recurring/${recurringId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
const statusText = isActive ?
window.getTranslation('recurring.activated', 'Recurring expense activated') :
window.getTranslation('recurring.deactivated', 'Recurring expense deactivated');
showToast(statusText, 'success');
loadRecurringExpenses();
} catch (error) {
console.error('Failed to toggle recurring status:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Delete recurring expense
async function deleteRecurring(recurringId) {
const confirmText = window.getTranslation('recurring.deleteConfirm', 'Are you sure you want to delete this recurring expense?');
if (!confirm(confirmText)) return;
try {
await apiCall(`/api/recurring/${recurringId}`, {
method: 'DELETE'
});
showToast(window.getTranslation('recurring.deleted', 'Recurring expense deleted'), 'success');
loadRecurringExpenses();
} catch (error) {
console.error('Failed to delete recurring expense:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Edit recurring expense
function editRecurring(recurringId) {
const recurring = currentRecurring.find(r => r.id === recurringId);
if (!recurring) return;
// Populate form
document.getElementById('recurring-id').value = recurring.id;
document.getElementById('recurring-name').value = recurring.name;
document.getElementById('recurring-amount').value = recurring.amount;
document.getElementById('recurring-category').value = recurring.category_id;
document.getElementById('recurring-frequency').value = recurring.frequency;
document.getElementById('recurring-day').value = recurring.day_of_period || '';
document.getElementById('recurring-next-due').value = recurring.next_due_date.split('T')[0];
document.getElementById('recurring-auto-create').checked = recurring.auto_create;
document.getElementById('recurring-notes').value = recurring.notes || '';
// Update modal title
document.getElementById('modal-title').textContent = window.getTranslation('recurring.edit', 'Edit Recurring Expense');
document.getElementById('recurring-submit-btn').textContent = window.getTranslation('actions.update', 'Update');
// Show modal
document.getElementById('add-recurring-modal').classList.remove('hidden');
}
// Show add recurring modal
function showAddRecurringModal() {
document.getElementById('recurring-form').reset();
document.getElementById('recurring-id').value = '';
document.getElementById('modal-title').textContent = window.getTranslation('recurring.add', 'Add Recurring Expense');
document.getElementById('recurring-submit-btn').textContent = window.getTranslation('actions.save', 'Save');
document.getElementById('add-recurring-modal').classList.remove('hidden');
}
// Close modal
function closeRecurringModal() {
document.getElementById('add-recurring-modal').classList.add('hidden');
}
// Save recurring expense
async function saveRecurringExpense(event) {
event.preventDefault();
const recurringId = document.getElementById('recurring-id').value;
const formData = {
name: document.getElementById('recurring-name').value,
amount: parseFloat(document.getElementById('recurring-amount').value),
// Don't send currency - let backend use current_user.currency from settings
category_id: parseInt(document.getElementById('recurring-category').value),
frequency: document.getElementById('recurring-frequency').value,
day_of_period: parseInt(document.getElementById('recurring-day').value) || null,
next_due_date: document.getElementById('recurring-next-due').value,
auto_create: document.getElementById('recurring-auto-create').checked,
notes: document.getElementById('recurring-notes').value
};
try {
if (recurringId) {
// Update
await apiCall(`/api/recurring/${recurringId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
showToast(window.getTranslation('recurring.updated', 'Recurring expense updated'), 'success');
} else {
// Create
await apiCall('/api/recurring/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
showToast(window.getTranslation('recurring.created', 'Recurring expense created'), 'success');
}
closeRecurringModal();
loadRecurringExpenses();
} catch (error) {
console.error('Failed to save recurring expense:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Detect recurring patterns
async function detectRecurringPatterns() {
const detectBtn = document.getElementById('detect-btn');
const originalText = detectBtn.innerHTML;
detectBtn.innerHTML = '<span class="material-symbols-outlined animate-spin">refresh</span> ' +
window.getTranslation('recurring.detecting', 'Detecting...');
detectBtn.disabled = true;
try {
const data = await apiCall('/api/recurring/detect', {
method: 'POST'
});
detectedSuggestions = data.suggestions || [];
if (detectedSuggestions.length === 0) {
showToast(window.getTranslation('recurring.noPatterns', 'No recurring patterns detected'), 'info');
} else {
displaySuggestions(detectedSuggestions);
document.getElementById('suggestions-section').classList.remove('hidden');
showToast(window.getTranslation('recurring.patternsFound', `Found ${detectedSuggestions.length} potential recurring expenses`), 'success');
}
} catch (error) {
console.error('Failed to detect patterns:', error);
showToast(window.getTranslation('recurring.errorDetecting', 'Failed to detect patterns'), 'error');
} finally {
detectBtn.innerHTML = originalText;
detectBtn.disabled = false;
}
}
// Display suggestions
function displaySuggestions(suggestions) {
const container = document.getElementById('suggestions-list');
container.innerHTML = suggestions.map((s, index) => `
<div class="bg-white dark:bg-[#0f1419] border border-blue-500/30 dark:border-blue-500/30 rounded-xl p-5">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 flex-1">
<div class="size-12 rounded-full flex items-center justify-center shrink-0 bg-blue-500/10">
<span class="material-symbols-outlined text-[24px] text-blue-400">auto_awesome</span>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-text-main dark:text-white font-semibold mb-1">${s.name}</h4>
<div class="flex flex-wrap items-center gap-2 text-sm text-text-muted dark:text-[#92adc9] mb-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs" style="background: ${s.category_color}20; color: ${s.category_color};">
${s.category_name}
</span>
<span></span>
<span>${window.getTranslation(`recurring.frequency.${s.frequency}`, s.frequency)}</span>
<span></span>
<span>${s.occurrences} ${window.getTranslation('recurring.occurrences', 'occurrences')}</span>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="text-text-main dark:text-white font-semibold">
${formatCurrency(s.amount, window.userCurrency || s.currency)}
</div>
<div class="text-blue-400">
<span class="material-symbols-outlined text-[16px] align-middle mr-1">verified</span>
${Math.round(s.confidence_score)}% ${window.getTranslation('recurring.confidence', 'confidence')}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button onclick="acceptSuggestion(${index})"
class="px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
${window.getTranslation('recurring.accept', 'Accept')}
</button>
<button onclick="dismissSuggestion(${index})"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-text-muted dark:text-[#92adc9] transition-colors"
title="${window.getTranslation('recurring.dismiss', 'Dismiss')}">
<span class="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
</div>
</div>
`).join('');
}
// Accept suggestion
async function acceptSuggestion(index) {
const suggestion = detectedSuggestions[index];
try {
await apiCall('/api/recurring/accept-suggestion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(suggestion)
});
showToast(window.getTranslation('recurring.suggestionAccepted', 'Recurring expense added'), 'success');
// Remove suggestion
detectedSuggestions.splice(index, 1);
if (detectedSuggestions.length === 0) {
document.getElementById('suggestions-section').classList.add('hidden');
} else {
displaySuggestions(detectedSuggestions);
}
loadRecurringExpenses();
} catch (error) {
console.error('Failed to accept suggestion:', error);
showToast(window.getTranslation('common.error', 'An error occurred'), 'error');
}
}
// Dismiss suggestion
function dismissSuggestion(index) {
detectedSuggestions.splice(index, 1);
if (detectedSuggestions.length === 0) {
document.getElementById('suggestions-section').classList.add('hidden');
} else {
displaySuggestions(detectedSuggestions);
}
}
// Load categories for dropdown
async function loadCategories() {
try {
const data = await apiCall('/api/expenses/categories');
const select = document.getElementById('recurring-category');
select.innerHTML = data.categories.map(cat =>
`<option value="${cat.id}">${cat.name}</option>`
).join('');
} catch (error) {
console.error('Failed to load categories:', error);
}
}
// Update day field based on frequency
function updateDayField() {
const frequency = document.getElementById('recurring-frequency').value;
const dayContainer = document.getElementById('day-container');
const dayInput = document.getElementById('recurring-day');
const dayLabel = document.getElementById('day-label');
if (frequency === 'weekly') {
dayContainer.classList.remove('hidden');
dayLabel.textContent = window.getTranslation('recurring.dayOfWeek', 'Day of week');
dayInput.type = 'select';
dayInput.innerHTML = `
<option value="0">${window.getTranslation('days.monday', 'Monday')}</option>
<option value="1">${window.getTranslation('days.tuesday', 'Tuesday')}</option>
<option value="2">${window.getTranslation('days.wednesday', 'Wednesday')}</option>
<option value="3">${window.getTranslation('days.thursday', 'Thursday')}</option>
<option value="4">${window.getTranslation('days.friday', 'Friday')}</option>
<option value="5">${window.getTranslation('days.saturday', 'Saturday')}</option>
<option value="6">${window.getTranslation('days.sunday', 'Sunday')}</option>
`;
} else if (frequency === 'monthly') {
dayContainer.classList.remove('hidden');
dayLabel.textContent = window.getTranslation('recurring.dayOfMonth', 'Day of month');
dayInput.type = 'number';
dayInput.min = '1';
dayInput.max = '28';
} else {
dayContainer.classList.add('hidden');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
if (document.getElementById('recurring-list')) {
await loadUserCurrency();
// Sync all recurring expenses to user's current currency
await syncRecurringCurrency();
loadRecurringExpenses();
loadCategories();
// Set default next due date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
document.getElementById('recurring-next-due').valueAsDate = tomorrow;
// Event listeners
document.getElementById('recurring-form')?.addEventListener('submit', saveRecurringExpense);
document.getElementById('recurring-frequency')?.addEventListener('change', updateDayField);
}
});
// Sync recurring expenses currency with user profile
async function syncRecurringCurrency() {
try {
await apiCall('/api/recurring/sync-currency', {
method: 'POST'
});
} catch (error) {
console.error('Failed to sync currency:', error);
}
}

600
app/static/js/reports.js Normal file
View file

@ -0,0 +1,600 @@
// 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 || 'GBP';
// Update KPI cards
document.getElementById('total-spent').textContent = formatCurrency(data.total_spent, window.userCurrency);
document.getElementById('total-income').textContent = formatCurrency(data.total_income, window.userCurrency);
document.getElementById('profit-loss').textContent = formatCurrency(Math.abs(data.profit_loss), window.userCurrency);
// Update profit/loss card color based on value
const profitCard = document.getElementById('profit-loss').closest('.bg-card-light, .dark\\:bg-card-dark');
if (profitCard) {
if (data.profit_loss >= 0) {
profitCard.classList.add('border-green-500/20');
profitCard.classList.remove('border-red-500/20');
document.getElementById('profit-loss').classList.add('text-green-600', 'dark:text-green-400');
document.getElementById('profit-loss').classList.remove('text-red-600', 'dark:text-red-400');
} else {
profitCard.classList.add('border-red-500/20');
profitCard.classList.remove('border-green-500/20');
document.getElementById('profit-loss').classList.add('text-red-600', 'dark:text-red-400');
document.getElementById('profit-loss').classList.remove('text-green-600', 'dark:text-green-400');
}
}
// 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)}%
`;
// Income change indicator
const incomeChange = document.getElementById('income-change');
const incomeChangeValue = data.income_percent_change || 0;
const isIncomeIncrease = incomeChangeValue > 0;
incomeChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isIncomeIncrease
? 'text-green-500 dark:text-green-400 bg-green-500/10'
: 'text-red-500 dark:text-red-400 bg-red-500/10'
}`;
incomeChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isIncomeIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(incomeChangeValue).toFixed(1)}%
`;
// Profit/loss change indicator
const profitChange = document.getElementById('profit-change');
const profitChangeValue = data.profit_percent_change || 0;
const isProfitIncrease = profitChangeValue > 0;
profitChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isProfitIncrease
? 'text-green-500 dark:text-green-400 bg-green-500/10'
: 'text-red-500 dark:text-red-400 bg-red-500/10'
}`;
profitChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isProfitIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(profitChangeValue).toFixed(1)}%
`;
// 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.toFixed(1)}%`;
// Savings rate change indicator
const savingsChange = document.getElementById('savings-change');
const savingsChangeValue = data.savings_rate_change;
const isSavingsIncrease = savingsChangeValue > 0;
savingsChange.className = `flex items-center font-medium px-1.5 py-0.5 rounded ${
isSavingsIncrease
? 'text-green-500 dark:text-green-400 bg-green-500/10'
: 'text-red-500 dark:text-red-400 bg-red-500/10'
}`;
savingsChange.innerHTML = `
<span class="material-symbols-outlined text-[14px] mr-0.5">${isSavingsIncrease ? 'trending_up' : 'trending_down'}</span>
${Math.abs(savingsChangeValue).toFixed(1)}%
`;
// Update charts
updateTrendChart(data.daily_trend);
updateCategoryChart(data.category_breakdown);
updateIncomeChart(data.income_breakdown);
updateMonthlyChart(data.monthly_comparison);
}
// Update trend chart - Income vs Expenses
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();
}
// Check if we have income data
const hasIncome = dailyData.length > 0 && dailyData[0].hasOwnProperty('income');
const datasets = hasIncome ? [
{
label: window.getTranslation ? window.getTranslation('nav.income', 'Income') : 'Income',
data: dailyData.map(d => d.income || 0),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: isDark ? '#1e293b' : '#ffffff',
pointBorderColor: '#10b981',
pointBorderWidth: 2,
pointHoverRadius: 6
},
{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Expenses') : 'Expenses',
data: dailyData.map(d => d.expenses || 0),
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: isDark ? '#1e293b' : '#ffffff',
pointBorderColor: '#ef4444',
pointBorderWidth: 2,
pointHoverRadius: 6
}
] : [{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Spending') : 'Spending',
data: dailyData.map(d => d.amount || d.expenses || 0),
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
}];
trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: dailyData.map(d => d.date),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: textColor,
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
titleColor: isDark ? '#f8fafc' : '#0f172a',
bodyColor: isDark ? '#94a3b8' : '#64748b',
borderColor: isDark ? '#334155' : '#e2e8f0',
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + formatCurrency(context.parsed.y, window.userCurrency || 'GBP');
}
}
}
},
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 formatCurrency(value, window.userCurrency || 'GBP');
}
}
}
}
}
});
}
// Update income sources pie chart
function updateIncomeChart(incomeBreakdown) {
const pieChart = document.getElementById('income-pie-chart');
const pieTotal = document.getElementById('income-pie-total');
const pieLegend = document.getElementById('income-legend');
if (!pieChart || !pieLegend) return;
const userCurrency = window.userCurrency || 'GBP';
if (!incomeBreakdown || incomeBreakdown.length === 0) {
pieChart.style.background = 'conic-gradient(#10b981 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">' +
(window.getTranslation ? window.getTranslation('dashboard.noData', 'No income data') : 'No income data') + '</p>';
return;
}
// Calculate total
const total = incomeBreakdown.reduce((sum, inc) => sum + parseFloat(inc.amount || 0), 0);
if (pieTotal) pieTotal.textContent = formatCurrency(total, userCurrency);
// Income source colors
const incomeColors = {
'Salary': '#10b981',
'Freelance': '#3b82f6',
'Investment': '#8b5cf6',
'Rental': '#f59e0b',
'Gift': '#ec4899',
'Bonus': '#14b8a6',
'Refund': '#6366f1',
'Other': '#6b7280'
};
// Generate conic gradient segments
let currentPercent = 0;
const gradientSegments = incomeBreakdown.map(inc => {
const percent = inc.percentage || 0;
const color = incomeColors[inc.source] || '#10b981';
const segment = `${color} ${currentPercent}% ${currentPercent + percent}%`;
currentPercent += percent;
return segment;
});
// Apply gradient
pieChart.style.background = `conic-gradient(${gradientSegments.join(', ')})`;
// Generate compact legend
const legendHTML = incomeBreakdown.map(inc => {
const color = incomeColors[inc.source] || '#10b981';
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: ${color};"></span>
<span class="text-text-muted dark:text-[#92adc9] text-[10px] truncate flex-1 leading-tight">${inc.source}</span>
<span class="text-text-muted dark:text-[#92adc9] text-[10px] font-medium">${inc.percentage}%</span>
</div>
`;
}).join('');
pieLegend.innerHTML = legendHTML;
}
// 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 || 'GBP';
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 - Income vs Expenses
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();
}
// Check if we have income data
const hasIncome = monthlyData.length > 0 && monthlyData[0].hasOwnProperty('income');
const datasets = hasIncome ? [
{
label: window.getTranslation ? window.getTranslation('nav.income', 'Income') : 'Income',
data: monthlyData.map(d => d.income || 0),
backgroundColor: '#10b981',
borderRadius: 6,
barPercentage: 0.5,
categoryPercentage: 0.7,
hoverBackgroundColor: '#059669'
},
{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Expenses') : 'Expenses',
data: monthlyData.map(d => d.expenses || d.amount || 0),
backgroundColor: '#ef4444',
borderRadius: 6,
barPercentage: 0.5,
categoryPercentage: 0.7,
hoverBackgroundColor: '#dc2626'
}
] : [{
label: window.getTranslation ? window.getTranslation('dashboard.spending', 'Monthly Spending') : 'Monthly Spending',
data: monthlyData.map(d => d.amount || d.expenses || 0),
backgroundColor: '#2b8cee',
borderRadius: 6,
barPercentage: 0.5,
categoryPercentage: 0.7,
hoverBackgroundColor: '#1d7ad9'
}];
monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: monthlyData.map(d => d.month),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: textColor,
usePointStyle: true,
padding: 15
}
},
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) {
return context.dataset.label + ': ' + formatCurrency(context.parsed.y, window.userCurrency || 'GBP');
}
}
}
},
scales: {
x: {
grid: {
display: false,
drawBorder: false
},
ticks: {
color: textColor
}
},
y: {
grid: {
color: gridColor,
drawBorder: false
},
ticks: {
color: textColor,
callback: function(value) {
return formatCurrency(value, window.userCurrency || 'GBP');
}
}
}
}
}
});
}
// 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();
});

319
app/static/js/search.js Normal file
View file

@ -0,0 +1,319 @@
// Global Search Component
// Provides unified search across all app content and features
let searchTimeout;
let currentSearchQuery = '';
// Initialize global search
document.addEventListener('DOMContentLoaded', () => {
initGlobalSearch();
});
function initGlobalSearch() {
const searchBtn = document.getElementById('global-search-btn');
const searchModal = document.getElementById('global-search-modal');
const searchInput = document.getElementById('global-search-input');
const searchResults = document.getElementById('global-search-results');
const searchClose = document.getElementById('global-search-close');
if (!searchBtn || !searchModal) return;
// Open search modal
searchBtn?.addEventListener('click', () => {
searchModal.classList.remove('hidden');
setTimeout(() => {
searchModal.classList.add('opacity-100');
searchInput?.focus();
}, 10);
});
// Close search modal
searchClose?.addEventListener('click', closeSearchModal);
// Close on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) {
closeSearchModal();
}
// Open search with Ctrl+K or Cmd+K
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
searchBtn?.click();
}
});
// Close on backdrop click
searchModal?.addEventListener('click', (e) => {
if (e.target === searchModal) {
closeSearchModal();
}
});
// Handle search input
searchInput?.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Clear previous timeout
clearTimeout(searchTimeout);
// Show loading state
if (query.length >= 2) {
searchResults.innerHTML = '<div class="p-4 text-center text-text-muted dark:text-[#92adc9]">Searching...</div>';
// Debounce search
searchTimeout = setTimeout(() => {
performSearch(query);
}, 300);
} else if (query.length === 0) {
showSearchPlaceholder();
} else {
searchResults.innerHTML = '<div class="p-4 text-center text-text-muted dark:text-[#92adc9]" data-translate="search.minChars">Type at least 2 characters to search</div>';
}
});
// Handle keyboard navigation
searchInput?.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const firstResult = searchResults.querySelector('[data-search-result]');
firstResult?.focus();
}
});
}
function closeSearchModal() {
const searchModal = document.getElementById('global-search-modal');
const searchInput = document.getElementById('global-search-input');
searchModal?.classList.remove('opacity-100');
setTimeout(() => {
searchModal?.classList.add('hidden');
searchInput.value = '';
showSearchPlaceholder();
}, 200);
}
function showSearchPlaceholder() {
const searchResults = document.getElementById('global-search-results');
searchResults.innerHTML = `
<div class="p-8 text-center">
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search</span>
<p class="text-text-muted dark:text-[#92adc9] text-sm" data-translate="search.placeholder">Search for transactions, documents, categories, or features</p>
<p class="text-text-muted dark:text-[#92adc9] text-xs mt-2" data-translate="search.hint">Press Ctrl+K to open search</p>
</div>
`;
}
async function performSearch(query) {
currentSearchQuery = query;
const searchResults = document.getElementById('global-search-results');
try {
const response = await apiCall(`/api/search/?q=${encodeURIComponent(query)}`, {
method: 'GET'
});
if (response.success) {
displaySearchResults(response);
} else {
searchResults.innerHTML = `<div class="p-4 text-center text-red-500">${response.message}</div>`;
}
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<div class="p-4 text-center text-red-500" data-translate="search.error">Search failed. Please try again.</div>';
}
}
function displaySearchResults(response) {
const searchResults = document.getElementById('global-search-results');
const results = response.results;
const userLang = localStorage.getItem('language') || 'en';
if (response.total_results === 0) {
searchResults.innerHTML = `
<div class="p-8 text-center">
<span class="material-symbols-outlined text-5xl text-text-muted dark:text-[#92adc9] opacity-50 mb-3">search_off</span>
<p class="text-text-muted dark:text-[#92adc9]" data-translate="search.noResults">No results found for "${response.query}"</p>
</div>
`;
return;
}
let html = '<div class="flex flex-col divide-y divide-border-light dark:divide-[#233648]">';
// Features
if (results.features && results.features.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.features">Features</h3><div class="flex flex-col gap-2">';
results.features.forEach(feature => {
const name = userLang === 'ro' ? feature.name_ro : feature.name;
const desc = userLang === 'ro' ? feature.description_ro : feature.description;
html += `
<a href="${feature.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<span class="material-symbols-outlined text-primary text-xl">${feature.icon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${name}</div>
<div class="text-xs text-text-muted dark:text-[#92adc9]">${desc}</div>
</div>
<span class="material-symbols-outlined text-text-muted text-sm">arrow_forward</span>
</a>
`;
});
html += '</div></div>';
}
// Expenses
if (results.expenses && results.expenses.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.expenses">Expenses</h3><div class="flex flex-col gap-2">';
results.expenses.forEach(expense => {
const date = new Date(expense.date).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
const ocrBadge = expense.ocr_match ? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="search.ocrMatch">OCR Match</span>' : '';
html += `
<a href="${expense.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${expense.category_color}20">
<span class="material-symbols-outlined text-lg" style="color: ${expense.category_color}">receipt</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${expense.description}</div>
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
<span>${expense.category_name}</span>
<span></span>
<span>${date}</span>
${ocrBadge}
</div>
</div>
<div class="text-sm font-semibold text-text-main dark:text-white">${formatCurrency(expense.amount, expense.currency)}</div>
</a>
`;
});
html += '</div></div>';
}
// Documents
if (results.documents && results.documents.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.documents">Documents</h3><div class="flex flex-col gap-2">';
results.documents.forEach(doc => {
const date = new Date(doc.created_at).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
const ocrBadge = doc.ocr_match ? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="search.ocrMatch">OCR Match</span>' : '';
const fileIcon = doc.file_type === 'PDF' ? 'picture_as_pdf' : 'image';
html += `
<button onclick="openDocumentFromSearch(${doc.id}, '${doc.file_type}', '${escapeHtml(doc.filename)}')" data-search-result tabindex="0" class="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary text-left">
<span class="material-symbols-outlined text-primary text-xl">${fileIcon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white truncate">${doc.filename}</div>
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
<span>${doc.file_type}</span>
<span></span>
<span>${date}</span>
${ocrBadge}
</div>
</div>
<span class="material-symbols-outlined text-text-muted text-sm">visibility</span>
</button>
`;
});
html += '</div></div>';
}
// Categories
if (results.categories && results.categories.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.categories">Categories</h3><div class="flex flex-col gap-2">';
results.categories.forEach(category => {
html += `
<a href="${category.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${category.color}20">
<span class="material-symbols-outlined text-lg" style="color: ${category.color}">${category.icon}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${category.name}</div>
</div>
<span class="material-symbols-outlined text-text-muted text-sm">arrow_forward</span>
</a>
`;
});
html += '</div></div>';
}
// Recurring Expenses
if (results.recurring && results.recurring.length > 0) {
html += '<div class="p-4"><h3 class="text-xs font-semibold text-text-muted dark:text-[#92adc9] uppercase mb-3" data-translate="search.recurring">Recurring</h3><div class="flex flex-col gap-2">';
results.recurring.forEach(rec => {
const nextDue = new Date(rec.next_due_date).toLocaleDateString(userLang === 'ro' ? 'ro-RO' : 'en-US', { month: 'short', day: 'numeric' });
const statusBadge = rec.is_active
? '<span class="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded" data-translate="recurring.active">Active</span>'
: '<span class="text-xs bg-gray-100 dark:bg-gray-800/30 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded" data-translate="recurring.inactive">Inactive</span>';
html += `
<a href="${rec.url}" data-search-result tabindex="0" class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-[#233648] transition-colors focus:outline-none focus:ring-2 focus:ring-primary">
<div class="size-10 rounded-lg flex items-center justify-center" style="background-color: ${rec.category_color}20">
<span class="material-symbols-outlined text-lg" style="color: ${rec.category_color}">repeat</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-white">${rec.name}</div>
<div class="flex items-center gap-2 text-xs text-text-muted dark:text-[#92adc9] mt-1">
<span>${rec.category_name}</span>
<span></span>
<span data-translate="recurring.nextDue">Next:</span>
<span>${nextDue}</span>
${statusBadge}
</div>
</div>
<div class="text-sm font-semibold text-text-main dark:text-white">${formatCurrency(rec.amount, rec.currency)}</div>
</a>
`;
});
html += '</div></div>';
}
html += '</div>';
searchResults.innerHTML = html;
// Apply translations
if (window.applyTranslations) {
window.applyTranslations();
}
// Handle keyboard navigation between results
const resultElements = searchResults.querySelectorAll('[data-search-result]');
resultElements.forEach((element, index) => {
element.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
resultElements[index + 1]?.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (index === 0) {
document.getElementById('global-search-input')?.focus();
} else {
resultElements[index - 1]?.focus();
}
}
});
});
}
// Open document viewer from search
function openDocumentFromSearch(docId, fileType, filename) {
// Close search modal
closeSearchModal();
// Navigate to documents page and open viewer
if (window.location.pathname !== '/documents') {
// Store document to open after navigation
sessionStorage.setItem('openDocumentId', docId);
sessionStorage.setItem('openDocumentType', fileType);
sessionStorage.setItem('openDocumentName', filename);
window.location.href = '/documents';
} else {
// Already on documents page, open directly
if (typeof viewDocument === 'function') {
viewDocument(docId, fileType, filename);
}
}
}
// Helper to escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

274
app/static/js/settings.js Normal file
View 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;
}

309
app/static/js/tags.js Normal file
View file

@ -0,0 +1,309 @@
// Tags Management JavaScript
// Handles tag creation, editing, filtering, and display
let allTags = [];
let selectedTags = [];
// Load all tags for current user
async function loadTags() {
try {
const response = await apiCall('/api/tags/?sort_by=use_count&order=desc');
if (response.success) {
allTags = response.tags;
return allTags;
}
} catch (error) {
console.error('Failed to load tags:', error);
return [];
}
}
// Load popular tags (most used)
async function loadPopularTags(limit = 10) {
try {
const response = await apiCall(`/api/tags/popular?limit=${limit}`);
if (response.success) {
return response.tags;
}
} catch (error) {
console.error('Failed to load popular tags:', error);
return [];
}
}
// Create a new tag
async function createTag(tagData) {
try {
const response = await apiCall('/api/tags/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData)
});
if (response.success) {
showToast(window.getTranslation('tags.created', 'Tag created successfully'), 'success');
await loadTags();
return response.tag;
} else {
showToast(response.message || window.getTranslation('tags.errorCreating', 'Error creating tag'), 'error');
return null;
}
} catch (error) {
console.error('Failed to create tag:', error);
showToast(window.getTranslation('tags.errorCreating', 'Error creating tag'), 'error');
return null;
}
}
// Update an existing tag
async function updateTag(tagId, tagData) {
try {
const response = await apiCall(`/api/tags/${tagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData)
});
if (response.success) {
showToast(window.getTranslation('tags.updated', 'Tag updated successfully'), 'success');
await loadTags();
return response.tag;
} else {
showToast(response.message || window.getTranslation('tags.errorUpdating', 'Error updating tag'), 'error');
return null;
}
} catch (error) {
console.error('Failed to update tag:', error);
showToast(window.getTranslation('tags.errorUpdating', 'Error updating tag'), 'error');
return null;
}
}
// Delete a tag
async function deleteTag(tagId) {
const confirmMsg = window.getTranslation('tags.deleteConfirm', 'Are you sure you want to delete this tag?');
if (!confirm(confirmMsg)) {
return false;
}
try {
const response = await apiCall(`/api/tags/${tagId}`, {
method: 'DELETE'
});
if (response.success) {
showToast(window.getTranslation('tags.deleted', 'Tag deleted successfully'), 'success');
await loadTags();
return true;
} else {
showToast(response.message || window.getTranslation('tags.errorDeleting', 'Error deleting tag'), 'error');
return false;
}
} catch (error) {
console.error('Failed to delete tag:', error);
showToast(window.getTranslation('tags.errorDeleting', 'Error deleting tag'), 'error');
return false;
}
}
// Get tag suggestions based on text
async function getTagSuggestions(text, maxTags = 5) {
try {
const response = await apiCall('/api/tags/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, max_tags: maxTags })
});
if (response.success) {
return response.suggested_tags;
}
return [];
} catch (error) {
console.error('Failed to get tag suggestions:', error);
return [];
}
}
// Render a single tag badge
function renderTagBadge(tag, options = {}) {
const { removable = false, clickable = false, onRemove = null, onClick = null } = options;
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-all';
badge.style.backgroundColor = `${tag.color}20`;
badge.style.borderColor = `${tag.color}40`;
badge.style.color = tag.color;
badge.classList.add('border');
if (clickable) {
badge.classList.add('cursor-pointer', 'hover:brightness-110');
badge.addEventListener('click', () => onClick && onClick(tag));
}
// Icon
const icon = document.createElement('span');
icon.className = 'material-symbols-outlined';
icon.style.fontSize = '14px';
icon.textContent = tag.icon || 'label';
badge.appendChild(icon);
// Tag name
const name = document.createElement('span');
name.textContent = tag.name;
badge.appendChild(name);
// Use count (optional)
if (tag.use_count > 0 && !removable) {
const count = document.createElement('span');
count.className = 'opacity-60';
count.textContent = `(${tag.use_count})`;
badge.appendChild(count);
}
// Remove button (optional)
if (removable) {
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-1 hover:bg-black hover:bg-opacity-10 rounded-full p-0.5';
removeBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size: 14px;">close</span>';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
onRemove && onRemove(tag);
});
badge.appendChild(removeBtn);
}
return badge;
}
// Render tags list in a container
function renderTagsList(tags, containerId, options = {}) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
if (tags.length === 0) {
const emptyMsg = document.createElement('p');
emptyMsg.className = 'text-text-muted dark:text-[#92adc9] text-sm';
emptyMsg.textContent = window.getTranslation('tags.noTags', 'No tags yet');
container.appendChild(emptyMsg);
return;
}
tags.forEach(tag => {
const badge = renderTagBadge(tag, options);
container.appendChild(badge);
});
}
// Create a tag filter dropdown
function createTagFilterDropdown(containerId, onSelectionChange) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = `
<div class="relative">
<button id="tagFilterBtn" class="flex items-center gap-2 px-4 py-2 bg-white dark:bg-[#0a1628] border border-gray-200 dark:border-white/10 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
<span class="material-symbols-outlined text-[20px]">label</span>
<span data-translate="tags.filterByTags">Filter by Tags</span>
<span class="material-symbols-outlined text-[16px]">expand_more</span>
</button>
<div id="tagFilterDropdown" class="absolute top-full left-0 mt-2 w-72 bg-white dark:bg-[#0a1628] border border-gray-200 dark:border-white/10 rounded-lg shadow-lg p-4 hidden z-50">
<div class="mb-3">
<input type="text" id="tagFilterSearch" placeholder="${window.getTranslation('tags.selectTags', 'Select tags...')}" class="w-full px-3 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm">
</div>
<div id="tagFilterList" class="max-h-64 overflow-y-auto space-y-2">
<!-- Tag checkboxes will be inserted here -->
</div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-white/10">
<button id="clearTagFilters" class="text-sm text-primary hover:underline">Clear all</button>
</div>
</div>
</div>
`;
const btn = container.querySelector('#tagFilterBtn');
const dropdown = container.querySelector('#tagFilterDropdown');
const searchInput = container.querySelector('#tagFilterSearch');
const listContainer = container.querySelector('#tagFilterList');
const clearBtn = container.querySelector('#clearTagFilters');
// Toggle dropdown
btn.addEventListener('click', async () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) {
await renderTagFilterList(listContainer, searchInput, onSelectionChange);
}
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// Clear filters
clearBtn.addEventListener('click', () => {
selectedTags = [];
renderTagFilterList(listContainer, searchInput, onSelectionChange);
onSelectionChange(selectedTags);
});
}
// Render tag filter list with checkboxes
async function renderTagFilterList(listContainer, searchInput, onSelectionChange) {
const tags = await loadTags();
const renderList = (filteredTags) => {
listContainer.innerHTML = '';
filteredTags.forEach(tag => {
const item = document.createElement('label');
item.className = 'flex items-center gap-2 p-2 hover:bg-gray-50 dark:hover:bg-white/5 rounded cursor-pointer';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = tag.id;
checkbox.checked = selectedTags.includes(tag.id);
checkbox.className = 'rounded';
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
selectedTags.push(tag.id);
} else {
selectedTags = selectedTags.filter(id => id !== tag.id);
}
onSelectionChange(selectedTags);
});
const badge = renderTagBadge(tag, {});
item.appendChild(checkbox);
item.appendChild(badge);
listContainer.appendChild(item);
});
};
// Initial render
renderList(tags);
// Search functionality
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = tags.filter(tag => tag.name.toLowerCase().includes(query));
renderList(filtered);
});
}
// Make functions globally available
window.loadTags = loadTags;
window.loadPopularTags = loadPopularTags;
window.createTag = createTag;
window.updateTag = updateTag;
window.deleteTag = deleteTag;
window.getTagSuggestions = getTagSuggestions;
window.renderTagBadge = renderTagBadge;
window.renderTagsList = renderTagsList;
window.createTagFilterDropdown = createTagFilterDropdown;

View 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, tx.currency || window.userCurrency || 'GBP')}</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();
});