2025-12-23 13:58:43 +00:00
|
|
|
|
class PersonalFinanceTracker {
|
2025-08-28 16:36:40 +01:00
|
|
|
|
constructor() {
|
|
|
|
|
|
this.trades = [];
|
|
|
|
|
|
this.currentPrices = new Map(); // Store current market prices
|
2025-08-28 19:20:36 +01:00
|
|
|
|
this.cgtSettings = null; // Store CGT settings
|
2025-08-28 16:36:40 +01:00
|
|
|
|
this.currentUser = null;
|
|
|
|
|
|
this.apiUrl = '/api';
|
|
|
|
|
|
this.initializeApp();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async initializeApp() {
|
|
|
|
|
|
this.bindEvents();
|
|
|
|
|
|
this.bindNavigation();
|
|
|
|
|
|
this.bindAuthEvents();
|
|
|
|
|
|
this.setDefaultDateTime();
|
|
|
|
|
|
|
|
|
|
|
|
// Check if user is logged in
|
|
|
|
|
|
const isAuthenticated = await this.checkAuthentication();
|
|
|
|
|
|
if (isAuthenticated) {
|
|
|
|
|
|
await this.loadTrades();
|
2025-08-28 19:20:36 +01:00
|
|
|
|
await this.loadCGTSettings();
|
2025-09-01 16:42:00 +01:00
|
|
|
|
await this.loadLatestPrices();
|
2025-08-28 16:36:40 +01:00
|
|
|
|
this.renderTrades();
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.showPage('dashboard');
|
|
|
|
|
|
this.showAppContent();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showLoginPage();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindEvents() {
|
|
|
|
|
|
const form = document.getElementById('trade-form');
|
|
|
|
|
|
const clearBtn = document.getElementById('clear-trades');
|
|
|
|
|
|
const exportBtn = document.getElementById('export-trades');
|
2025-09-18 10:29:34 +01:00
|
|
|
|
const subscriptionForm = document.getElementById('subscription-form');
|
2025-08-28 16:36:40 +01:00
|
|
|
|
|
|
|
|
|
|
form.addEventListener('submit', (e) => this.handleSubmit(e));
|
|
|
|
|
|
clearBtn.addEventListener('click', () => this.clearAllTrades());
|
|
|
|
|
|
exportBtn.addEventListener('click', () => this.exportTrades());
|
2025-09-18 10:29:34 +01:00
|
|
|
|
|
|
|
|
|
|
if (subscriptionForm) {
|
|
|
|
|
|
subscriptionForm.addEventListener('submit', (e) => this.handleSubscriptionSubmit(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const editSubscriptionForm = document.getElementById('edit-subscription-form');
|
|
|
|
|
|
if (editSubscriptionForm) {
|
|
|
|
|
|
editSubscriptionForm.addEventListener('submit', (e) => this.handleEditSubscriptionSubmit(e));
|
|
|
|
|
|
}
|
2025-08-28 16:36:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindNavigation() {
|
|
|
|
|
|
const menuItems = document.querySelectorAll('.menu-item');
|
|
|
|
|
|
const sidebarToggle = document.querySelector('.sidebar-toggle');
|
|
|
|
|
|
const sidebar = document.querySelector('.sidebar');
|
|
|
|
|
|
|
|
|
|
|
|
menuItems.forEach(item => {
|
|
|
|
|
|
item.addEventListener('click', () => {
|
|
|
|
|
|
const page = item.dataset.page;
|
2025-09-18 10:29:34 +01:00
|
|
|
|
this.navigateToPage(page);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
sidebarToggle.addEventListener('click', () => {
|
|
|
|
|
|
sidebar.classList.toggle('open');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Close sidebar when clicking outside on mobile
|
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
|
if (window.innerWidth <= 768 &&
|
|
|
|
|
|
!sidebar.contains(e.target) &&
|
|
|
|
|
|
!sidebarToggle.contains(e.target)) {
|
|
|
|
|
|
sidebar.classList.remove('open');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-18 10:29:34 +01:00
|
|
|
|
navigateToPage(pageId) {
|
|
|
|
|
|
const menuItems = document.querySelectorAll('.menu-item');
|
|
|
|
|
|
const sidebar = document.querySelector('.sidebar');
|
|
|
|
|
|
|
|
|
|
|
|
// Show the page
|
|
|
|
|
|
this.showPage(pageId);
|
|
|
|
|
|
|
|
|
|
|
|
// Update active menu item
|
|
|
|
|
|
menuItems.forEach(mi => mi.classList.remove('active'));
|
|
|
|
|
|
const targetMenuItem = document.querySelector(`[data-page="${pageId}"]`);
|
|
|
|
|
|
if (targetMenuItem) {
|
|
|
|
|
|
targetMenuItem.classList.add('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Hide sidebar on mobile after selection
|
|
|
|
|
|
if (window.innerWidth <= 768) {
|
|
|
|
|
|
sidebar.classList.remove('open');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-28 21:37:57 +01:00
|
|
|
|
|
2025-08-28 16:36:40 +01:00
|
|
|
|
showPage(pageId) {
|
|
|
|
|
|
const pages = document.querySelectorAll('.page');
|
|
|
|
|
|
const pageTitle = document.getElementById('page-title');
|
|
|
|
|
|
|
|
|
|
|
|
pages.forEach(page => {
|
|
|
|
|
|
page.classList.remove('active');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const targetPage = document.getElementById(`${pageId}-page`);
|
|
|
|
|
|
if (targetPage) {
|
|
|
|
|
|
targetPage.classList.add('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update page title
|
|
|
|
|
|
const titles = {
|
|
|
|
|
|
'dashboard': 'Dashboard',
|
|
|
|
|
|
'add-trade': 'Add Trade',
|
|
|
|
|
|
'trade-history': 'Trade History',
|
|
|
|
|
|
'portfolio': 'Portfolio',
|
|
|
|
|
|
'gains-losses': 'Gains & Losses',
|
2025-08-28 19:20:36 +01:00
|
|
|
|
'cgt-settings': 'CGT Settings',
|
2025-08-29 19:54:29 +01:00
|
|
|
|
'tokens': 'API Tokens',
|
|
|
|
|
|
'cash-accounts': 'Cash Accounts',
|
|
|
|
|
|
'add-transfer': 'Add Transfer',
|
2025-09-18 10:29:34 +01:00
|
|
|
|
'subscriptions': 'Subscriptions',
|
|
|
|
|
|
'add-subscription': 'Add Subscription',
|
2025-08-28 16:36:40 +01:00
|
|
|
|
'admin': 'Admin Panel'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
pageTitle.textContent = titles[pageId] || 'Dashboard';
|
|
|
|
|
|
|
|
|
|
|
|
// Update specific pages when shown
|
|
|
|
|
|
if (pageId === 'portfolio') {
|
|
|
|
|
|
this.renderPortfolioPage();
|
|
|
|
|
|
} else if (pageId === 'gains-losses') {
|
|
|
|
|
|
this.renderGainsLossesPage();
|
2025-08-28 19:20:36 +01:00
|
|
|
|
} else if (pageId === 'cgt-settings') {
|
|
|
|
|
|
this.renderCGTSettingsPage();
|
2025-08-29 19:54:29 +01:00
|
|
|
|
} else if (pageId === 'tokens') {
|
|
|
|
|
|
this.renderTokensPage();
|
|
|
|
|
|
} else if (pageId === 'cash-accounts') {
|
|
|
|
|
|
this.renderCashAccountsPage();
|
2025-09-19 13:11:20 +01:00
|
|
|
|
// Auto-scroll to add account form if coming from quick action
|
|
|
|
|
|
if (document.referrer === '' || window.location.hash === '#add-account') {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const addAccountForm = document.getElementById('create-account-form');
|
|
|
|
|
|
if (addAccountForm) {
|
|
|
|
|
|
addAccountForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
|
const firstInput = addAccountForm.querySelector('input[type="text"]');
|
|
|
|
|
|
if (firstInput) firstInput.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
}
|
2025-08-29 19:54:29 +01:00
|
|
|
|
} else if (pageId === 'add-transfer') {
|
|
|
|
|
|
this.renderAddTransferPage();
|
2025-09-18 10:29:34 +01:00
|
|
|
|
} else if (pageId === 'subscriptions') {
|
|
|
|
|
|
this.renderSubscriptionsPage();
|
|
|
|
|
|
} else if (pageId === 'add-subscription') {
|
|
|
|
|
|
this.renderAddSubscriptionPage();
|
2025-09-19 13:11:20 +01:00
|
|
|
|
// Auto-focus first input when coming from quick action
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const firstInput = document.getElementById('subscription-service-name');
|
|
|
|
|
|
if (firstInput) firstInput.focus();
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
} else if (pageId === 'add-trade') {
|
|
|
|
|
|
// Auto-focus first input when coming from quick action
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const firstInput = document.getElementById('etf-symbol');
|
|
|
|
|
|
if (firstInput) firstInput.focus();
|
|
|
|
|
|
}, 100);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
} else if (pageId === 'admin') {
|
|
|
|
|
|
this.renderAdminPage();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindAuthEvents() {
|
|
|
|
|
|
const loginForm = document.getElementById('login-form');
|
|
|
|
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
|
|
|
|
const createUserForm = document.getElementById('create-user-form');
|
|
|
|
|
|
|
|
|
|
|
|
if (loginForm) {
|
|
|
|
|
|
loginForm.addEventListener('submit', (e) => this.handleLogin(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (logoutBtn) {
|
|
|
|
|
|
logoutBtn.addEventListener('click', () => this.handleLogout());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (createUserForm) {
|
|
|
|
|
|
createUserForm.addEventListener('submit', (e) => this.handleCreateUser(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async checkAuthentication() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/me`, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.currentUser = await response.json();
|
|
|
|
|
|
this.updateUserInfo();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.log('Not authenticated');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showLoginPage() {
|
|
|
|
|
|
document.querySelector('.sidebar').style.display = 'none';
|
|
|
|
|
|
document.getElementById('login-page').classList.add('active');
|
|
|
|
|
|
document.getElementById('page-title').textContent = 'Login';
|
|
|
|
|
|
|
|
|
|
|
|
// Hide all other pages
|
|
|
|
|
|
document.querySelectorAll('.page:not(#login-page)').forEach(page => {
|
|
|
|
|
|
page.classList.remove('active');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showAppContent() {
|
|
|
|
|
|
document.querySelector('.sidebar').style.display = 'block';
|
|
|
|
|
|
document.getElementById('login-page').classList.remove('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleLogin(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const username = document.getElementById('login-username').value;
|
|
|
|
|
|
const password = document.getElementById('login-password').value;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/login`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify({ username, password })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.currentUser = await response.json();
|
|
|
|
|
|
this.updateUserInfo();
|
|
|
|
|
|
this.showAppContent();
|
|
|
|
|
|
|
|
|
|
|
|
// Load user data
|
|
|
|
|
|
await this.loadTrades();
|
|
|
|
|
|
this.renderTrades();
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.showPage('dashboard');
|
|
|
|
|
|
|
|
|
|
|
|
this.showNotification('Login successful!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
this.showNotification(error.error || 'Login failed', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Login error:', error);
|
|
|
|
|
|
this.showNotification('Login failed', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleLogout() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch(`${this.apiUrl}/logout`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.currentUser = null;
|
|
|
|
|
|
this.trades = [];
|
|
|
|
|
|
this.currentPrices.clear();
|
|
|
|
|
|
this.showLoginPage();
|
|
|
|
|
|
this.showNotification('Logged out successfully', 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Logout error:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateUserInfo() {
|
|
|
|
|
|
const userInfo = document.getElementById('user-info');
|
|
|
|
|
|
const currentUserSpan = document.getElementById('current-user');
|
|
|
|
|
|
const adminMenus = document.querySelectorAll('.admin-only');
|
|
|
|
|
|
|
|
|
|
|
|
if (this.currentUser) {
|
|
|
|
|
|
userInfo.style.display = 'block';
|
|
|
|
|
|
currentUserSpan.textContent = `${this.currentUser.username} ${this.currentUser.isAdmin ? '(Admin)' : ''}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Show/hide admin menus
|
|
|
|
|
|
adminMenus.forEach(menu => {
|
|
|
|
|
|
menu.style.display = this.currentUser.isAdmin ? 'block' : 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
userInfo.style.display = 'none';
|
|
|
|
|
|
adminMenus.forEach(menu => {
|
|
|
|
|
|
menu.style.display = 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleCreateUser(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const username = document.getElementById('new-username').value;
|
|
|
|
|
|
const password = document.getElementById('new-password').value;
|
|
|
|
|
|
const email = document.getElementById('new-email').value;
|
|
|
|
|
|
const isAdmin = document.getElementById('new-is-admin').checked;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/admin/users`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify({ username, password, email, isAdmin })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
document.getElementById('create-user-form').reset();
|
|
|
|
|
|
this.loadUsers(); // Refresh users list
|
|
|
|
|
|
this.showNotification('User created successfully!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
this.showNotification(error.error || 'Failed to create user', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Create user error:', error);
|
|
|
|
|
|
this.showNotification('Failed to create user', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async renderAdminPage() {
|
|
|
|
|
|
if (!this.currentUser || !this.currentUser.isAdmin) {
|
|
|
|
|
|
this.showNotification('Admin access required', 'error');
|
|
|
|
|
|
this.showPage('dashboard');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.loadUsers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadUsers() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/admin/users`, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const users = await response.json();
|
|
|
|
|
|
this.renderUsersList(users);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Failed to load users', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Load users error:', error);
|
|
|
|
|
|
this.showNotification('Failed to load users', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderUsersList(users) {
|
|
|
|
|
|
const usersList = document.getElementById('users-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (users.length === 0) {
|
|
|
|
|
|
usersList.innerHTML = '<p class="no-data">No users found</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const usersHTML = users.map(user => {
|
|
|
|
|
|
const createdDate = new Date(user.created_at).toLocaleDateString();
|
|
|
|
|
|
const lastLogin = user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="user-item">
|
|
|
|
|
|
<div class="user-header">
|
|
|
|
|
|
<span class="user-username">${user.username}</span>
|
|
|
|
|
|
<div class="user-badges">
|
|
|
|
|
|
${user.is_admin ? '<span class="admin-badge">Admin</span>' : '<span class="user-badge">User</span>'}
|
|
|
|
|
|
${user.id === this.currentUser.id ? '<span class="current-badge">You</span>' : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${user.id !== this.currentUser.id ? `<button class="delete-user-btn" onclick="app.deleteUser(${user.id})">Delete</button>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="user-details">
|
|
|
|
|
|
<div class="user-info">
|
|
|
|
|
|
<span>Email: ${user.email || 'Not provided'}</span>
|
|
|
|
|
|
<span>Created: ${createdDate}</span>
|
|
|
|
|
|
<span>Last Login: ${lastLogin}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
usersList.innerHTML = usersHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async deleteUser(userId) {
|
|
|
|
|
|
if (!confirm('Are you sure you want to delete this user? This will also delete all their trades.')) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/admin/users/${userId}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.loadUsers(); // Refresh users list
|
|
|
|
|
|
this.showNotification('User deleted successfully', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
this.showNotification(error.error || 'Failed to delete user', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Delete user error:', error);
|
|
|
|
|
|
this.showNotification('Failed to delete user', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setDefaultDateTime() {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const dateInput = document.getElementById('trade-date');
|
|
|
|
|
|
const timeInput = document.getElementById('trade-time');
|
|
|
|
|
|
|
|
|
|
|
|
dateInput.value = now.toISOString().split('T')[0];
|
|
|
|
|
|
timeInput.value = now.toTimeString().split(' ')[0].substring(0, 5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleSubmit(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData(e.target);
|
|
|
|
|
|
const trade = this.createTradeFromForm(formData);
|
|
|
|
|
|
|
|
|
|
|
|
if (this.validateTrade(trade)) {
|
|
|
|
|
|
await this.addTrade(trade);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
createTradeFromForm(formData) {
|
|
|
|
|
|
const etfSymbol = document.getElementById('etf-symbol').value.trim().toUpperCase();
|
|
|
|
|
|
const tradeType = document.getElementById('trade-type').value;
|
|
|
|
|
|
const shares = parseFloat(document.getElementById('shares').value);
|
|
|
|
|
|
const price = parseFloat(document.getElementById('price').value);
|
|
|
|
|
|
const currency = document.getElementById('currency').value;
|
|
|
|
|
|
const tradeDate = document.getElementById('trade-date').value;
|
|
|
|
|
|
const tradeTime = document.getElementById('trade-time').value;
|
|
|
|
|
|
const fees = parseFloat(document.getElementById('fees').value) || 0;
|
|
|
|
|
|
const notes = document.getElementById('notes').value.trim();
|
|
|
|
|
|
|
|
|
|
|
|
const dateTime = new Date(`${tradeDate}T${tradeTime}`);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
etfSymbol,
|
|
|
|
|
|
tradeType,
|
|
|
|
|
|
shares,
|
|
|
|
|
|
price,
|
|
|
|
|
|
currency,
|
|
|
|
|
|
dateTime: dateTime.toISOString(),
|
|
|
|
|
|
fees,
|
|
|
|
|
|
notes
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
validateTrade(trade) {
|
|
|
|
|
|
if (!trade.etfSymbol || !trade.tradeType || !trade.shares || !trade.price || !trade.currency) {
|
|
|
|
|
|
this.showNotification('Please fill in all required fields', 'error');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (trade.shares <= 0 || trade.price <= 0) {
|
|
|
|
|
|
this.showNotification('Shares and price must be greater than 0', 'error');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async addTrade(trade) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/trades`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify(trade)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to add trade');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const newTrade = await response.json();
|
|
|
|
|
|
this.trades.unshift(newTrade);
|
|
|
|
|
|
this.renderTrades();
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.resetForm();
|
|
|
|
|
|
this.showNotification('Trade added successfully!', 'success');
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate to dashboard after successful trade
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.showPage('dashboard');
|
|
|
|
|
|
const menuItems = document.querySelectorAll('.menu-item');
|
|
|
|
|
|
menuItems.forEach(mi => mi.classList.remove('active'));
|
|
|
|
|
|
document.querySelector('[data-page="dashboard"]').classList.add('active');
|
|
|
|
|
|
}, 1500);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding trade:', error);
|
|
|
|
|
|
this.showNotification(error.message || 'Failed to add trade', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderTrades() {
|
|
|
|
|
|
const tradesList = document.getElementById('trades-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (this.trades.length === 0) {
|
|
|
|
|
|
tradesList.innerHTML = '<p class="no-trades">No trades recorded yet. Add your first trade above!</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tradesHTML = this.trades.map(trade => this.createTradeHTML(trade)).join('');
|
|
|
|
|
|
tradesList.innerHTML = tradesHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
createTradeHTML(trade) {
|
|
|
|
|
|
const dateTime = new Date(trade.dateTime);
|
|
|
|
|
|
const formattedDate = dateTime.toLocaleDateString();
|
|
|
|
|
|
const formattedTime = dateTime.toTimeString().split(' ')[0];
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(trade.currency);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="trade-item ${trade.tradeType}" data-id="${trade.id}">
|
|
|
|
|
|
<div class="trade-header">
|
|
|
|
|
|
<span class="etf-symbol">${trade.etfSymbol}</span>
|
|
|
|
|
|
<span class="trade-type ${trade.tradeType}">${trade.tradeType.toUpperCase()}</span>
|
|
|
|
|
|
<button class="delete-btn" onclick="app.deleteTrade('${trade.id}')">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="trade-details">
|
|
|
|
|
|
<div class="trade-info">
|
|
|
|
|
|
<span class="shares">${trade.shares} shares</span>
|
|
|
|
|
|
<span class="price">${currencySymbol}${trade.price.toFixed(2)} per share</span>
|
|
|
|
|
|
<span class="total-value">Total: ${currencySymbol}${trade.totalValue.toFixed(2)}</span>
|
|
|
|
|
|
${trade.fees > 0 ? `<span class="fees">Fees: ${currencySymbol}${trade.fees.toFixed(2)}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="trade-datetime">
|
|
|
|
|
|
<span class="date">${formattedDate}</span>
|
|
|
|
|
|
<span class="time">${formattedTime}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${trade.notes ? `<div class="trade-notes">${trade.notes}</div>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async deleteTrade(tradeId) {
|
|
|
|
|
|
if (confirm('Are you sure you want to delete this trade?')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/trades/${tradeId}`, {
|
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to delete trade');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.trades = this.trades.filter(trade => trade.id !== tradeId);
|
|
|
|
|
|
this.renderTrades();
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.showNotification('Trade deleted successfully!', 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error deleting trade:', error);
|
|
|
|
|
|
this.showNotification(error.message || 'Failed to delete trade', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async clearAllTrades() {
|
|
|
|
|
|
if (confirm('Are you sure you want to delete all trades? This action cannot be undone.')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/trades`, {
|
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to clear trades');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.trades = [];
|
|
|
|
|
|
this.renderTrades();
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.showNotification('All trades cleared!', 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error clearing trades:', error);
|
|
|
|
|
|
this.showNotification(error.message || 'Failed to clear trades', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
exportTrades() {
|
|
|
|
|
|
if (this.trades.length === 0) {
|
|
|
|
|
|
this.showNotification('No trades to export', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dataStr = JSON.stringify(this.trades, null, 2);
|
|
|
|
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = URL.createObjectURL(dataBlob);
|
|
|
|
|
|
link.download = `etf-trades-${new Date().toISOString().split('T')[0]}.json`;
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
|
|
|
|
|
|
this.showNotification('Trades exported successfully!', 'success');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resetForm() {
|
|
|
|
|
|
document.getElementById('trade-form').reset();
|
|
|
|
|
|
this.setDefaultDateTime();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadTrades() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/trades`);
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to load trades');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.trades = await response.json();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading trades:', error);
|
|
|
|
|
|
this.showNotification('Failed to load trades from server', 'error');
|
|
|
|
|
|
this.trades = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showNotification(message, type = 'info') {
|
|
|
|
|
|
const notification = document.createElement('div');
|
|
|
|
|
|
notification.className = `notification ${type}`;
|
|
|
|
|
|
notification.textContent = message;
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
notification.classList.add('show');
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
notification.classList.remove('show');
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
document.body.removeChild(notification);
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async getPortfolioSummary() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/portfolio-summary`);
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to fetch portfolio summary');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching portfolio summary:', error);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDashboard() {
|
2025-09-18 10:29:34 +01:00
|
|
|
|
this.calculateSimplifiedDashboard();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async calculateSimplifiedDashboard() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Calculate portfolio totals
|
|
|
|
|
|
const portfolioData = this.calculatePortfolioTotals();
|
|
|
|
|
|
|
|
|
|
|
|
// Load cash and subscription data in parallel
|
|
|
|
|
|
const [cashData, subscriptionData] = await Promise.all([
|
|
|
|
|
|
this.fetchCashSummary(),
|
|
|
|
|
|
this.fetchSubscriptionsSummary()
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Update dashboard with calculated data
|
|
|
|
|
|
this.updateDashboardSummary(portfolioData, cashData, subscriptionData);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating dashboard:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
calculatePortfolioTotals() {
|
|
|
|
|
|
let totalCostBasis = 0;
|
|
|
|
|
|
let totalCurrentValue = 0;
|
|
|
|
|
|
let hasCurrentPrices = false;
|
|
|
|
|
|
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
totalCostBasis += etf.totalValue;
|
|
|
|
|
|
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol);
|
|
|
|
|
|
if (currentPrice) {
|
|
|
|
|
|
totalCurrentValue += etf.shares * currentPrice;
|
|
|
|
|
|
hasCurrentPrices = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
totalCurrentValue += etf.totalValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const totalGains = totalCurrentValue - totalCostBasis;
|
|
|
|
|
|
const gainsPercentage = totalCostBasis > 0 ? ((totalGains / totalCostBasis) * 100) : 0;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
currentValue: totalCurrentValue,
|
|
|
|
|
|
totalGains,
|
|
|
|
|
|
gainsPercentage,
|
|
|
|
|
|
hasCurrentPrices
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fetchCashSummary() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-summary`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching cash summary:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { total_eur: 0, total_usd: 0, account_count: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fetchSubscriptionsSummary() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
console.log('Subscription summary data:', data); // Debug log
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching subscriptions summary:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDashboardSummary(portfolioData, cashData, subscriptionData) {
|
|
|
|
|
|
// Portfolio card - force EUR display
|
|
|
|
|
|
document.getElementById('dashboard-current-value').textContent = this.formatCurrency(portfolioData.currentValue, 'EUR');
|
|
|
|
|
|
document.getElementById('dashboard-total-gains').textContent =
|
|
|
|
|
|
`${portfolioData.totalGains >= 0 ? '+' : ''}${this.formatCurrency(portfolioData.totalGains, 'EUR')}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Cash card - force EUR display
|
|
|
|
|
|
const totalCash = cashData.total_eur + (cashData.total_usd * 1.1); // Simple EUR conversion
|
|
|
|
|
|
document.getElementById('dashboard-cash-total').textContent = this.formatCurrency(totalCash, 'EUR');
|
|
|
|
|
|
document.getElementById('dashboard-account-count').textContent = `${cashData.account_count} accounts`;
|
|
|
|
|
|
|
|
|
|
|
|
// Subscriptions card - using summary data structure
|
|
|
|
|
|
let monthlyTotal = 0;
|
|
|
|
|
|
let subscriptionCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (subscriptionData && Array.isArray(subscriptionData) && subscriptionData.length > 0) {
|
|
|
|
|
|
// If we get an array of summary objects (grouped by currency)
|
|
|
|
|
|
subscriptionData.forEach(summary => {
|
|
|
|
|
|
monthlyTotal += summary.total_monthly_cost || 0;
|
|
|
|
|
|
subscriptionCount += summary.total_subscriptions || 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (subscriptionData && subscriptionData.total_monthly_cost !== undefined) {
|
|
|
|
|
|
// If we get a single summary object
|
|
|
|
|
|
monthlyTotal = subscriptionData.total_monthly_cost || 0;
|
|
|
|
|
|
subscriptionCount = subscriptionData.total_subscriptions || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('dashboard-subscription-monthly').textContent = this.formatCurrency(monthlyTotal, 'EUR') + '/mo';
|
|
|
|
|
|
document.getElementById('dashboard-subscription-count').textContent = `${subscriptionCount} services`;
|
|
|
|
|
|
|
|
|
|
|
|
// Total value card - force EUR display
|
|
|
|
|
|
const totalValue = portfolioData.currentValue + totalCash;
|
|
|
|
|
|
document.getElementById('dashboard-total-combined').textContent = this.formatCurrency(totalValue, 'EUR');
|
|
|
|
|
|
document.getElementById('dashboard-performance').textContent =
|
|
|
|
|
|
`${portfolioData.gainsPercentage >= 0 ? '+' : ''}${portfolioData.gainsPercentage.toFixed(1)}%`;
|
2025-09-19 13:11:20 +01:00
|
|
|
|
|
|
|
|
|
|
// Load and display top accounts for each segment
|
|
|
|
|
|
this.loadDashboardTopAccounts();
|
2025-12-23 13:58:43 +00:00
|
|
|
|
|
|
|
|
|
|
// Initialize and update growth charts
|
|
|
|
|
|
this.initializeGrowthCharts();
|
|
|
|
|
|
this.updateGrowthCharts();
|
2025-08-28 16:36:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDashboardColors(totalGains) {
|
|
|
|
|
|
const gainsCard = document.querySelector('.dashboard-card.total-gains');
|
|
|
|
|
|
const currentValueCard = document.querySelector('.dashboard-card.total-value');
|
|
|
|
|
|
const costBasisCard = document.querySelector('.dashboard-card.cost-basis');
|
|
|
|
|
|
|
|
|
|
|
|
if (gainsCard) {
|
|
|
|
|
|
gainsCard.classList.remove('positive', 'negative', 'neutral');
|
|
|
|
|
|
if (totalGains > 0) {
|
|
|
|
|
|
gainsCard.classList.add('positive');
|
|
|
|
|
|
} else if (totalGains < 0) {
|
|
|
|
|
|
gainsCard.classList.add('negative');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
gainsCard.classList.add('neutral');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add subtle indicators to other cards
|
|
|
|
|
|
if (currentValueCard) {
|
|
|
|
|
|
currentValueCard.classList.remove('positive', 'negative');
|
|
|
|
|
|
if (totalGains !== 0) {
|
|
|
|
|
|
currentValueCard.classList.add(totalGains > 0 ? 'positive' : 'negative');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (costBasisCard) {
|
|
|
|
|
|
costBasisCard.classList.remove('positive', 'negative');
|
|
|
|
|
|
if (totalGains !== 0) {
|
|
|
|
|
|
costBasisCard.classList.add(totalGains > 0 ? 'positive' : 'negative');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-19 13:11:20 +01:00
|
|
|
|
async loadDashboardTopAccounts() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Load data for all segments in parallel
|
|
|
|
|
|
const [portfolioData, cashData, subscriptionData] = await Promise.all([
|
|
|
|
|
|
this.getTopPortfolioHoldings(),
|
|
|
|
|
|
this.getTopCashAccounts(),
|
|
|
|
|
|
this.getTopSubscriptions()
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Render each segment
|
|
|
|
|
|
this.renderTopPortfolio(portfolioData);
|
|
|
|
|
|
this.renderTopCash(cashData);
|
|
|
|
|
|
this.renderTopSubscriptions(subscriptionData);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading dashboard top accounts:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getTopPortfolioHoldings() {
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
const etfArray = Array.from(etfMap.values())
|
|
|
|
|
|
.filter(etf => etf.shares > 0)
|
|
|
|
|
|
.map(etf => {
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(etf.symbol);
|
|
|
|
|
|
const currentValue = currentPrice ? etf.shares * currentPrice : etf.totalValue;
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: etf.symbol,
|
|
|
|
|
|
value: currentValue,
|
|
|
|
|
|
details: `${etf.shares.toFixed(3)} shares`,
|
|
|
|
|
|
currency: etf.currency
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.sort((a, b) => b.value - a.value)
|
|
|
|
|
|
.slice(0, 5);
|
|
|
|
|
|
|
|
|
|
|
|
return etfArray;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async getTopCashAccounts() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const accounts = await response.json();
|
|
|
|
|
|
return accounts
|
|
|
|
|
|
.sort((a, b) => b.balance - a.balance)
|
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
|
.map(account => ({
|
|
|
|
|
|
name: account.name,
|
|
|
|
|
|
value: account.balance,
|
|
|
|
|
|
details: account.account_type,
|
|
|
|
|
|
currency: account.currency
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching top cash accounts:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async getTopSubscriptions() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const subscriptions = await response.json();
|
|
|
|
|
|
return subscriptions
|
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
|
const aCost = a.billingCycle === 'monthly' ? a.monthlyPrice : (a.annualPrice / 12);
|
|
|
|
|
|
const bCost = b.billingCycle === 'monthly' ? b.monthlyPrice : (b.annualPrice / 12);
|
|
|
|
|
|
return bCost - aCost;
|
|
|
|
|
|
})
|
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
|
.map(sub => ({
|
|
|
|
|
|
name: sub.serviceName,
|
|
|
|
|
|
value: sub.billingCycle === 'monthly' ? sub.monthlyPrice : (sub.annualPrice / 12),
|
|
|
|
|
|
details: sub.category,
|
|
|
|
|
|
currency: sub.currency
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching top subscriptions:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderTopPortfolio(holdings) {
|
|
|
|
|
|
const container = document.getElementById('dashboard-top-portfolio');
|
|
|
|
|
|
|
|
|
|
|
|
if (!holdings || holdings.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="no-data-small">No positions yet</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const html = holdings.map(holding => `
|
|
|
|
|
|
<div class="top-account-item">
|
|
|
|
|
|
<div class="top-account-name">${holding.name}</div>
|
|
|
|
|
|
<div class="top-account-details">${holding.details}</div>
|
|
|
|
|
|
<div class="top-account-value">${this.formatCurrency(holding.value, holding.currency)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderTopCash(accounts) {
|
|
|
|
|
|
const container = document.getElementById('dashboard-top-cash');
|
|
|
|
|
|
|
|
|
|
|
|
if (!accounts || accounts.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="no-data-small">No accounts yet</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const html = accounts.map(account => `
|
|
|
|
|
|
<div class="top-account-item">
|
|
|
|
|
|
<div class="top-account-name">${account.name}</div>
|
|
|
|
|
|
<div class="top-account-details">${account.details}</div>
|
|
|
|
|
|
<div class="top-account-value">${this.formatCurrency(account.value, account.currency)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderTopSubscriptions(subscriptions) {
|
|
|
|
|
|
const container = document.getElementById('dashboard-top-subscriptions');
|
|
|
|
|
|
|
|
|
|
|
|
if (!subscriptions || subscriptions.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="no-data-small">No subscriptions yet</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const html = subscriptions.map(sub => `
|
|
|
|
|
|
<div class="top-account-item">
|
|
|
|
|
|
<div class="top-account-name">${sub.name}</div>
|
|
|
|
|
|
<div class="top-account-details">${sub.details}</div>
|
|
|
|
|
|
<div class="top-account-value">${this.formatCurrency(sub.value, sub.currency)}/mo</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 16:36:40 +01:00
|
|
|
|
calculateDashboardMetrics() {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const currentMonth = now.getMonth();
|
|
|
|
|
|
const currentYear = now.getFullYear();
|
|
|
|
|
|
|
|
|
|
|
|
let totalCostBasis = 0;
|
|
|
|
|
|
let totalCurrentValue = 0;
|
|
|
|
|
|
let totalShares = 0;
|
|
|
|
|
|
let monthlyInvestment = 0;
|
|
|
|
|
|
let yearlyInvestment = 0;
|
|
|
|
|
|
let monthlyTrades = 0;
|
|
|
|
|
|
let yearlyTrades = 0;
|
|
|
|
|
|
let uniqueETFs = new Set();
|
|
|
|
|
|
let hasCurrentPrices = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate portfolio metrics using ETF positions
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
totalCostBasis += etf.totalValue;
|
|
|
|
|
|
totalShares += etf.shares;
|
|
|
|
|
|
uniqueETFs.add(symbol);
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate current value using updated prices or cost basis
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol);
|
|
|
|
|
|
if (currentPrice) {
|
|
|
|
|
|
totalCurrentValue += etf.shares * currentPrice;
|
|
|
|
|
|
hasCurrentPrices = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
totalCurrentValue += etf.totalValue; // Use cost basis if no current price
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate trade-based metrics (monthly/yearly)
|
|
|
|
|
|
this.trades.forEach(trade => {
|
|
|
|
|
|
const tradeDate = new Date(trade.dateTime);
|
|
|
|
|
|
const tradeValue = trade.tradeType === 'buy' ? trade.totalValue : -trade.totalValue;
|
|
|
|
|
|
|
|
|
|
|
|
yearlyInvestment += tradeValue;
|
|
|
|
|
|
yearlyTrades++;
|
|
|
|
|
|
|
|
|
|
|
|
if (tradeDate.getMonth() === currentMonth && tradeDate.getFullYear() === currentYear) {
|
|
|
|
|
|
monthlyInvestment += tradeValue;
|
|
|
|
|
|
monthlyTrades++;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate gains/losses
|
|
|
|
|
|
const totalGains = totalCurrentValue - totalCostBasis;
|
|
|
|
|
|
const gainsPercentage = totalCostBasis > 0 ? ((totalGains / totalCostBasis) * 100) : 0;
|
|
|
|
|
|
const avgReturn = gainsPercentage;
|
|
|
|
|
|
|
|
|
|
|
|
// Update dashboard elements
|
|
|
|
|
|
document.getElementById('dashboard-current-value').textContent = this.formatCurrency(totalCurrentValue);
|
|
|
|
|
|
document.getElementById('dashboard-total-gains').textContent = this.formatCurrency(totalGains);
|
|
|
|
|
|
document.getElementById('dashboard-gains-percentage').textContent = `${totalGains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%`;
|
|
|
|
|
|
document.getElementById('dashboard-cost-basis').textContent = this.formatCurrency(totalCostBasis);
|
|
|
|
|
|
document.getElementById('dashboard-avg-return').textContent = `Avg return: ${avgReturn >= 0 ? '+' : ''}${avgReturn.toFixed(1)}%`;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('total-shares').textContent = totalShares.toFixed(3);
|
|
|
|
|
|
document.getElementById('unique-etfs').textContent = `${uniqueETFs.size} ETFs`;
|
|
|
|
|
|
document.getElementById('monthly-investment').textContent = this.formatCurrency(monthlyInvestment);
|
|
|
|
|
|
document.getElementById('monthly-trades').textContent = `${monthlyTrades} trades`;
|
|
|
|
|
|
document.getElementById('yearly-investment').textContent = this.formatCurrency(yearlyInvestment);
|
|
|
|
|
|
document.getElementById('yearly-trades').textContent = `${yearlyTrades} trades`;
|
|
|
|
|
|
|
|
|
|
|
|
// Update status indicators
|
|
|
|
|
|
const lastUpdatedElement = document.getElementById('dashboard-last-updated');
|
|
|
|
|
|
if (hasCurrentPrices) {
|
|
|
|
|
|
lastUpdatedElement.textContent = `Updated: ${new Date().toLocaleString()}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastUpdatedElement.textContent = 'Using cost basis - update prices in Gains/Losses';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update dashboard card colors for gains/losses
|
|
|
|
|
|
this.updateDashboardColors(totalGains);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
|
|
|
|
|
|
// Update total holdings card with portfolio data
|
|
|
|
|
|
this.updateTotalHoldingsCard();
|
2025-08-28 16:36:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderETFBreakdown() {
|
|
|
|
|
|
const etfMap = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
this.trades.forEach(trade => {
|
|
|
|
|
|
if (!etfMap.has(trade.etfSymbol)) {
|
|
|
|
|
|
etfMap.set(trade.etfSymbol, {
|
|
|
|
|
|
symbol: trade.etfSymbol,
|
|
|
|
|
|
shares: 0,
|
|
|
|
|
|
totalValue: 0,
|
|
|
|
|
|
currency: trade.currency,
|
|
|
|
|
|
trades: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const etf = etfMap.get(trade.etfSymbol);
|
|
|
|
|
|
const multiplier = trade.tradeType === 'buy' ? 1 : -1;
|
|
|
|
|
|
|
|
|
|
|
|
etf.shares += trade.shares * multiplier;
|
|
|
|
|
|
etf.totalValue += trade.totalValue * multiplier;
|
|
|
|
|
|
etf.trades++;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const breakdownList = document.getElementById('etf-breakdown-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (etfMap.size === 0) {
|
|
|
|
|
|
breakdownList.innerHTML = '<p class="no-data">No ETF positions yet</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const etfEntries = Array.from(etfMap.values()).filter(etf => etf.shares > 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (etfEntries.length === 0) {
|
|
|
|
|
|
breakdownList.innerHTML = '<p class="no-data">No current ETF positions</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const breakdownHTML = etfEntries.map(etf => {
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
const avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="etf-item">
|
|
|
|
|
|
<div class="etf-header">
|
|
|
|
|
|
<span class="etf-symbol">${etf.symbol}</span>
|
|
|
|
|
|
<span class="etf-value">${currencySymbol}${etf.totalValue.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="etf-details">
|
|
|
|
|
|
<span class="etf-shares">${etf.shares.toFixed(3)} shares</span>
|
|
|
|
|
|
<span class="etf-avg-price">Avg: ${currencySymbol}${avgPrice.toFixed(2)}</span>
|
|
|
|
|
|
<span class="etf-trades">${etf.trades} trades</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
breakdownList.innerHTML = breakdownHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-29 19:54:29 +01:00
|
|
|
|
async loadDashboardCashData() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Load cash summary
|
|
|
|
|
|
const summaryResponse = await fetch(`${this.apiUrl}/cash-summary`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (summaryResponse.ok) {
|
|
|
|
|
|
const summary = await summaryResponse.json();
|
|
|
|
|
|
this.updateDashboardCashSummary(summary);
|
|
|
|
|
|
|
|
|
|
|
|
// If there are cash accounts, also load detailed account list
|
|
|
|
|
|
if (summary.account_count > 0) {
|
|
|
|
|
|
const accountsResponse = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (accountsResponse.ok) {
|
|
|
|
|
|
const accounts = await accountsResponse.json();
|
|
|
|
|
|
this.renderDashboardCashAccounts(accounts);
|
|
|
|
|
|
|
|
|
|
|
|
// Show the cash breakdown section
|
|
|
|
|
|
const cashBreakdown = document.getElementById('cash-breakdown');
|
|
|
|
|
|
if (cashBreakdown) {
|
|
|
|
|
|
cashBreakdown.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Hide the cash breakdown section if no accounts
|
|
|
|
|
|
const cashBreakdown = document.getElementById('cash-breakdown');
|
|
|
|
|
|
if (cashBreakdown) {
|
|
|
|
|
|
cashBreakdown.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading dashboard cash data:', error);
|
|
|
|
|
|
// Hide the cash breakdown section on error
|
|
|
|
|
|
const cashBreakdown = document.getElementById('cash-breakdown');
|
|
|
|
|
|
if (cashBreakdown) {
|
|
|
|
|
|
cashBreakdown.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDashboardCashSummary(summary) {
|
|
|
|
|
|
// Update EUR amount
|
|
|
|
|
|
document.getElementById('dashboard-cash-eur').textContent = `€${summary.total_eur.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Update USD amount and show/hide USD line based on amount
|
|
|
|
|
|
const usdLine = document.getElementById('dashboard-cash-usd-line');
|
|
|
|
|
|
const usdAmount = document.getElementById('dashboard-cash-usd');
|
|
|
|
|
|
usdAmount.textContent = `$${summary.total_usd.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
|
|
|
|
|
|
if (summary.total_usd > 0) {
|
|
|
|
|
|
usdLine.style.display = 'flex';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
usdLine.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update account count
|
|
|
|
|
|
const accountCountText = summary.account_count === 1 ? '1 account' : `${summary.account_count} accounts`;
|
|
|
|
|
|
document.getElementById('dashboard-account-count').textContent = accountCountText;
|
|
|
|
|
|
|
|
|
|
|
|
// Update average interest and show/hide based on whether there are accounts with interest
|
|
|
|
|
|
const avgInterestDisplay = document.getElementById('dashboard-avg-interest-display');
|
|
|
|
|
|
if (summary.avg_interest_rate > 0) {
|
|
|
|
|
|
avgInterestDisplay.textContent = `Avg: ${summary.avg_interest_rate.toFixed(1)}%`;
|
|
|
|
|
|
avgInterestDisplay.style.display = 'block';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
avgInterestDisplay.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update total holdings card after cash summary is updated
|
|
|
|
|
|
this.updateTotalHoldingsCard();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateTotalHoldingsCard() {
|
|
|
|
|
|
// Get current portfolio value (from dashboard metric)
|
|
|
|
|
|
const portfolioValueElement = document.getElementById('dashboard-current-value');
|
|
|
|
|
|
let portfolioValue = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (portfolioValueElement && portfolioValueElement.textContent) {
|
|
|
|
|
|
// Parse the portfolio value (remove € and commas)
|
|
|
|
|
|
const portfolioText = portfolioValueElement.textContent.replace(/[€,]/g, '').trim();
|
|
|
|
|
|
portfolioValue = parseFloat(portfolioText) || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get current cash savings total (EUR equivalent)
|
|
|
|
|
|
let totalCashEUR = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Get EUR cash
|
|
|
|
|
|
const eurCashElement = document.getElementById('dashboard-cash-eur');
|
|
|
|
|
|
if (eurCashElement && eurCashElement.textContent) {
|
|
|
|
|
|
const eurText = eurCashElement.textContent.replace(/[€,]/g, '').trim();
|
|
|
|
|
|
totalCashEUR += parseFloat(eurText) || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get USD cash and convert to EUR (simplified conversion - ideally use actual rates)
|
|
|
|
|
|
const usdCashElement = document.getElementById('dashboard-cash-usd');
|
|
|
|
|
|
if (usdCashElement && usdCashElement.textContent && usdCashElement.closest('#dashboard-cash-usd-line').style.display !== 'none') {
|
|
|
|
|
|
const usdText = usdCashElement.textContent.replace(/[\$,]/g, '').trim();
|
|
|
|
|
|
const usdAmount = parseFloat(usdText) || 0;
|
|
|
|
|
|
// Convert USD to EUR (using approximate rate of 0.85, should be made configurable)
|
|
|
|
|
|
totalCashEUR += usdAmount * 0.85;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate total
|
|
|
|
|
|
const totalHoldings = portfolioValue + totalCashEUR;
|
|
|
|
|
|
|
|
|
|
|
|
// Update the display
|
|
|
|
|
|
document.getElementById('total-holdings-portfolio').textContent = `€${portfolioValue.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
document.getElementById('total-holdings-cash').textContent = `€${totalCashEUR.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
document.getElementById('total-holdings-combined').textContent = `€${totalHoldings.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderDashboardCashAccounts(accounts) {
|
|
|
|
|
|
const accountsList = document.getElementById('dashboard-cash-accounts-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (!accounts || accounts.length === 0) {
|
|
|
|
|
|
accountsList.innerHTML = '<p class="no-data">No cash accounts yet</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const accountTypeMap = {
|
|
|
|
|
|
savings: 'Savings',
|
|
|
|
|
|
checking: 'Checking',
|
|
|
|
|
|
money_market: 'Money Market',
|
|
|
|
|
|
cd: 'Certificate of Deposit',
|
|
|
|
|
|
other: 'Other'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
accountsList.innerHTML = accounts.slice(0, 5).map(account => {
|
|
|
|
|
|
const balance = parseFloat(account.balance);
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(account.currency);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
const formattedBalance = `${currencySymbol}${balance.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="cash-account-breakdown-item">
|
|
|
|
|
|
<div class="cash-account-info">
|
|
|
|
|
|
<div class="cash-account-name">${this.escapeHtml(account.account_name)}</div>
|
|
|
|
|
|
<div class="cash-account-type">${accountTypeMap[account.account_type] || 'Other'}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cash-account-balance">${formattedBalance}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
// Add "View All" link if there are more than 5 accounts
|
|
|
|
|
|
if (accounts.length > 5) {
|
|
|
|
|
|
accountsList.innerHTML += `
|
|
|
|
|
|
<div class="cash-account-breakdown-item" style="border-left: 3px solid #17a2b8; cursor: pointer;" onclick="app.showPage('cash-accounts')">
|
|
|
|
|
|
<div class="cash-account-info">
|
|
|
|
|
|
<div class="cash-account-name" style="color: #17a2b8;">View All Cash Accounts</div>
|
|
|
|
|
|
<div class="cash-account-type">+${accounts.length - 5} more accounts</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cash-account-balance" style="color: #17a2b8;">→</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 16:36:40 +01:00
|
|
|
|
renderPortfolioPage() {
|
|
|
|
|
|
const portfolioSummary = document.getElementById('portfolio-summary');
|
|
|
|
|
|
const etfMap = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
this.trades.forEach(trade => {
|
|
|
|
|
|
if (!etfMap.has(trade.etfSymbol)) {
|
|
|
|
|
|
etfMap.set(trade.etfSymbol, {
|
|
|
|
|
|
symbol: trade.etfSymbol,
|
|
|
|
|
|
shares: 0,
|
|
|
|
|
|
totalValue: 0,
|
|
|
|
|
|
currency: trade.currency,
|
|
|
|
|
|
trades: 0,
|
|
|
|
|
|
avgPrice: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const etf = etfMap.get(trade.etfSymbol);
|
|
|
|
|
|
const multiplier = trade.tradeType === 'buy' ? 1 : -1;
|
|
|
|
|
|
|
|
|
|
|
|
etf.shares += trade.shares * multiplier;
|
|
|
|
|
|
etf.totalValue += trade.totalValue * multiplier;
|
|
|
|
|
|
etf.trades++;
|
|
|
|
|
|
etf.avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const activeETFs = Array.from(etfMap.values()).filter(etf => etf.shares > 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (activeETFs.length === 0) {
|
|
|
|
|
|
portfolioSummary.innerHTML = '<p class="no-data">No active positions in your portfolio</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let totalPortfolioValue = 0;
|
|
|
|
|
|
activeETFs.forEach(etf => {
|
|
|
|
|
|
totalPortfolioValue += etf.totalValue;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const portfolioHTML = `
|
|
|
|
|
|
<div class="portfolio-overview">
|
|
|
|
|
|
<h3>Portfolio Overview</h3>
|
|
|
|
|
|
<div class="overview-stats">
|
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
|
<span class="stat-label">Total Value</span>
|
|
|
|
|
|
<span class="stat-value">${this.formatCurrency(totalPortfolioValue)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
|
<span class="stat-label">Active ETFs</span>
|
|
|
|
|
|
<span class="stat-value">${activeETFs.length}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
|
<span class="stat-label">Total Trades</span>
|
|
|
|
|
|
<span class="stat-value">${this.trades.length}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="portfolio-holdings">
|
|
|
|
|
|
<h3>Holdings</h3>
|
|
|
|
|
|
<div class="holdings-grid">
|
|
|
|
|
|
${activeETFs.map(etf => {
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
const allocation = ((etf.totalValue / totalPortfolioValue) * 100).toFixed(1);
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="holding-card">
|
|
|
|
|
|
<div class="holding-header">
|
|
|
|
|
|
<span class="holding-symbol">${etf.symbol}</span>
|
|
|
|
|
|
<span class="holding-allocation">${allocation}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="holding-details">
|
|
|
|
|
|
<div class="holding-stat">
|
|
|
|
|
|
<span>Shares</span>
|
|
|
|
|
|
<span>${etf.shares.toFixed(3)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="holding-stat">
|
|
|
|
|
|
<span>Value</span>
|
|
|
|
|
|
<span>${currencySymbol}${etf.totalValue.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="holding-stat">
|
|
|
|
|
|
<span>Avg Price</span>
|
|
|
|
|
|
<span>${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="holding-stat">
|
|
|
|
|
|
<span>Trades</span>
|
|
|
|
|
|
<span>${etf.trades}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
portfolioSummary.innerHTML = portfolioHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderGainsLossesPage() {
|
|
|
|
|
|
this.renderPriceUpdateList();
|
|
|
|
|
|
this.calculateAndDisplayPerformance();
|
|
|
|
|
|
this.bindGainsLossesEvents();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindGainsLossesEvents() {
|
|
|
|
|
|
const updateAllBtn = document.getElementById('update-all-prices');
|
|
|
|
|
|
if (updateAllBtn && !updateAllBtn.hasEventListener) {
|
|
|
|
|
|
updateAllBtn.addEventListener('click', () => this.updateAllPrices());
|
|
|
|
|
|
updateAllBtn.hasEventListener = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderPriceUpdateList() {
|
|
|
|
|
|
const priceUpdateList = document.getElementById('price-update-list');
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
|
|
|
|
|
|
if (etfMap.size === 0) {
|
|
|
|
|
|
priceUpdateList.innerHTML = '<p class="no-data">No ETF positions to update</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => {
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol) || '';
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="price-update-item">
|
|
|
|
|
|
<div class="price-update-header">
|
|
|
|
|
|
<span class="etf-symbol-update">${symbol}</span>
|
|
|
|
|
|
<span class="etf-position">${etf.shares.toFixed(3)} shares</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="price-update-controls">
|
|
|
|
|
|
<label>Current Price (${currencySymbol})</label>
|
|
|
|
|
|
<input type="number"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
placeholder="0.00"
|
|
|
|
|
|
value="${currentPrice}"
|
|
|
|
|
|
class="current-price-input"
|
|
|
|
|
|
data-symbol="${symbol}">
|
|
|
|
|
|
<button class="update-price-btn" data-symbol="${symbol}">Update</button>
|
2025-09-01 16:42:00 +01:00
|
|
|
|
<button class="view-history-btn" data-symbol="${symbol}">History</button>
|
2025-08-28 16:36:40 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="price-info">
|
|
|
|
|
|
<span>Avg Cost: ${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
|
|
|
|
|
|
<span class="price-change" data-symbol="${symbol}">-</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
priceUpdateList.innerHTML = updateHTML;
|
|
|
|
|
|
|
|
|
|
|
|
// Bind individual update events
|
|
|
|
|
|
document.querySelectorAll('.update-price-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
|
|
|
|
const symbol = e.target.dataset.symbol;
|
|
|
|
|
|
const input = document.querySelector(`input[data-symbol="${symbol}"]`);
|
|
|
|
|
|
const price = parseFloat(input.value);
|
|
|
|
|
|
|
|
|
|
|
|
if (price > 0) {
|
|
|
|
|
|
this.updateETFPrice(symbol, price);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Please enter a valid price', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-01 16:42:00 +01:00
|
|
|
|
// Bind history view events
|
|
|
|
|
|
document.querySelectorAll('.view-history-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', async (e) => {
|
|
|
|
|
|
const symbol = e.target.dataset.symbol;
|
|
|
|
|
|
await this.showPriceHistory(symbol);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-08-28 16:36:40 +01:00
|
|
|
|
// Update on Enter key
|
|
|
|
|
|
document.querySelectorAll('.current-price-input').forEach(input => {
|
|
|
|
|
|
input.addEventListener('keypress', (e) => {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
const symbol = e.target.dataset.symbol;
|
|
|
|
|
|
const price = parseFloat(e.target.value);
|
|
|
|
|
|
|
|
|
|
|
|
if (price > 0) {
|
|
|
|
|
|
this.updateETFPrice(symbol, price);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getActiveETFPositions() {
|
|
|
|
|
|
const etfMap = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
this.trades.forEach(trade => {
|
|
|
|
|
|
if (!etfMap.has(trade.etfSymbol)) {
|
|
|
|
|
|
etfMap.set(trade.etfSymbol, {
|
|
|
|
|
|
symbol: trade.etfSymbol,
|
|
|
|
|
|
shares: 0,
|
|
|
|
|
|
totalValue: 0,
|
|
|
|
|
|
currency: trade.currency,
|
|
|
|
|
|
trades: 0,
|
|
|
|
|
|
avgPrice: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const etf = etfMap.get(trade.etfSymbol);
|
|
|
|
|
|
const multiplier = trade.tradeType === 'buy' ? 1 : -1;
|
|
|
|
|
|
|
|
|
|
|
|
etf.shares += trade.shares * multiplier;
|
|
|
|
|
|
etf.totalValue += trade.totalValue * multiplier;
|
|
|
|
|
|
etf.trades++;
|
|
|
|
|
|
etf.avgPrice = etf.shares > 0 ? etf.totalValue / etf.shares : 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Filter out positions with 0 or negative shares
|
|
|
|
|
|
const activePositions = new Map();
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
if (etf.shares > 0) {
|
|
|
|
|
|
activePositions.set(symbol, etf);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return activePositions;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-01 16:42:00 +01:00
|
|
|
|
async updateETFPrice(symbol, currentPrice) {
|
|
|
|
|
|
// Get currency for this ETF from trades
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
const etf = etfMap.get(symbol);
|
|
|
|
|
|
const currency = etf ? etf.currency : 'USD';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Save to database
|
|
|
|
|
|
await this.savePriceToHistory(symbol, currentPrice, currency);
|
|
|
|
|
|
|
|
|
|
|
|
// Update in-memory price
|
|
|
|
|
|
this.currentPrices.set(symbol, currentPrice);
|
|
|
|
|
|
this.calculateAndDisplayPerformance();
|
|
|
|
|
|
this.updatePriceChangeIndicator(symbol, currentPrice);
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.showNotification(`Updated ${symbol} price to ${currentPrice} and saved to history`, 'success');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating price:', error);
|
|
|
|
|
|
this.showNotification('Error updating price: ' + error.message, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadLatestPrices() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/latest-prices`, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const prices = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
// Load into memory
|
|
|
|
|
|
for (const [symbol, priceData] of Object.entries(prices)) {
|
|
|
|
|
|
this.currentPrices.set(symbol, priceData.price);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading latest prices:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async savePriceToHistory(etf_symbol, price, currency) {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/price-history`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
etf_symbol,
|
|
|
|
|
|
price: parseFloat(price),
|
|
|
|
|
|
currency
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to save price history');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadPriceHistory(symbol, limit = 50) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/price-history/${symbol}?limit=${limit}`, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading price history:', error);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
2025-08-28 16:36:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updatePriceChangeIndicator(symbol, currentPrice) {
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
const etf = etfMap.get(symbol);
|
|
|
|
|
|
|
|
|
|
|
|
if (etf) {
|
|
|
|
|
|
const changeAmount = currentPrice - etf.avgPrice;
|
|
|
|
|
|
const changePercent = ((changeAmount / etf.avgPrice) * 100);
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
|
|
|
|
|
|
const changeElement = document.querySelector(`[data-symbol="${symbol}"].price-change`);
|
|
|
|
|
|
if (changeElement) {
|
|
|
|
|
|
const changeClass = changeAmount >= 0 ? 'positive' : 'negative';
|
|
|
|
|
|
const changeSign = changeAmount >= 0 ? '+' : '';
|
|
|
|
|
|
|
|
|
|
|
|
changeElement.className = `price-change ${changeClass}`;
|
|
|
|
|
|
changeElement.textContent = `${changeSign}${currencySymbol}${changeAmount.toFixed(2)} (${changeSign}${changePercent.toFixed(1)}%)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateAllPrices() {
|
|
|
|
|
|
const inputs = document.querySelectorAll('.current-price-input');
|
|
|
|
|
|
let updatedCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
inputs.forEach(input => {
|
|
|
|
|
|
const price = parseFloat(input.value);
|
|
|
|
|
|
if (price > 0) {
|
|
|
|
|
|
this.updateETFPrice(input.dataset.symbol, price);
|
|
|
|
|
|
updatedCount++;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (updatedCount > 0) {
|
|
|
|
|
|
this.showNotification(`Updated ${updatedCount} ETF prices`, 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Please enter valid prices first', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
calculateAndDisplayPerformance() {
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
let totalCost = 0;
|
|
|
|
|
|
let totalCurrentValue = 0;
|
|
|
|
|
|
let hasCurrentPrices = false;
|
|
|
|
|
|
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
totalCost += etf.totalValue;
|
|
|
|
|
|
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol);
|
|
|
|
|
|
if (currentPrice) {
|
|
|
|
|
|
totalCurrentValue += etf.shares * currentPrice;
|
|
|
|
|
|
hasCurrentPrices = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
totalCurrentValue += etf.totalValue; // Use cost basis if no current price
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const totalGainLoss = totalCurrentValue - totalCost;
|
|
|
|
|
|
const totalPercentage = totalCost > 0 ? ((totalGainLoss / totalCost) * 100) : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Update performance summary
|
|
|
|
|
|
document.getElementById('total-performance').textContent = this.formatCurrency(totalGainLoss);
|
|
|
|
|
|
document.getElementById('total-percentage').textContent = `${totalPercentage >= 0 ? '+' : ''}${totalPercentage.toFixed(1)}%`;
|
|
|
|
|
|
document.getElementById('unrealized-performance').textContent = this.formatCurrency(totalGainLoss);
|
|
|
|
|
|
document.getElementById('unrealized-percentage').textContent = `${totalPercentage >= 0 ? '+' : ''}${totalPercentage.toFixed(1)}%`;
|
|
|
|
|
|
document.getElementById('current-portfolio-value').textContent = this.formatCurrency(totalCurrentValue);
|
|
|
|
|
|
|
|
|
|
|
|
// Update performance card colors
|
|
|
|
|
|
const performanceCards = document.querySelectorAll('.performance-card');
|
|
|
|
|
|
performanceCards.forEach(card => {
|
|
|
|
|
|
if (card.classList.contains('total-gains') || card.classList.contains('unrealized-gains')) {
|
|
|
|
|
|
card.classList.remove('positive', 'negative');
|
|
|
|
|
|
card.classList.add(totalGainLoss >= 0 ? 'positive' : 'negative');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Update last updated timestamp
|
|
|
|
|
|
const lastUpdated = document.getElementById('last-updated');
|
|
|
|
|
|
if (hasCurrentPrices) {
|
|
|
|
|
|
lastUpdated.textContent = `Updated: ${new Date().toLocaleString()}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastUpdated.textContent = 'Using cost basis - update prices';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 19:20:36 +01:00
|
|
|
|
// Calculate and display CGT information
|
|
|
|
|
|
this.calculateAndDisplayCGT(etfMap, hasCurrentPrices);
|
|
|
|
|
|
|
2025-08-29 10:39:27 +01:00
|
|
|
|
// Calculate and display 8+ year CGT information
|
|
|
|
|
|
this.calculateAndDisplayLongTermCGT(etfMap, hasCurrentPrices);
|
|
|
|
|
|
|
2025-08-28 16:36:40 +01:00
|
|
|
|
this.renderGainsBreakdown(etfMap);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderGainsBreakdown(etfMap) {
|
|
|
|
|
|
const breakdownList = document.getElementById('gains-breakdown-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (etfMap.size === 0) {
|
|
|
|
|
|
breakdownList.innerHTML = '<p class="no-data">No active positions</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const breakdownHTML = Array.from(etfMap.entries()).map(([symbol, etf]) => {
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol) || etf.avgPrice;
|
|
|
|
|
|
const currentValue = etf.shares * currentPrice;
|
|
|
|
|
|
const gainLoss = currentValue - etf.totalValue;
|
|
|
|
|
|
const percentage = etf.totalValue > 0 ? ((gainLoss / etf.totalValue) * 100) : 0;
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
|
|
|
|
|
|
const performanceClass = gainLoss >= 0 ? 'positive' : 'negative';
|
|
|
|
|
|
const hasRealPrice = this.currentPrices.has(symbol);
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="gains-breakdown-item ${performanceClass}">
|
|
|
|
|
|
<div class="breakdown-header">
|
|
|
|
|
|
<span class="breakdown-symbol">${symbol}</span>
|
|
|
|
|
|
<span class="breakdown-performance">
|
|
|
|
|
|
${gainLoss >= 0 ? '+' : ''}${currencySymbol}${gainLoss.toFixed(2)}
|
|
|
|
|
|
(${gainLoss >= 0 ? '+' : ''}${percentage.toFixed(1)}%)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="breakdown-details">
|
|
|
|
|
|
<div class="breakdown-stat">
|
|
|
|
|
|
<span>Shares</span>
|
|
|
|
|
|
<span>${etf.shares.toFixed(3)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="breakdown-stat">
|
|
|
|
|
|
<span>Avg Cost</span>
|
|
|
|
|
|
<span>${currencySymbol}${etf.avgPrice.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="breakdown-stat">
|
|
|
|
|
|
<span>Current Price</span>
|
|
|
|
|
|
<span>${currencySymbol}${currentPrice.toFixed(2)} ${!hasRealPrice ? '(est.)' : ''}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="breakdown-stat">
|
|
|
|
|
|
<span>Current Value</span>
|
|
|
|
|
|
<span>${currencySymbol}${currentValue.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
breakdownList.innerHTML = breakdownHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 14:37:57 +01:00
|
|
|
|
formatCurrency(amount, currency = null) {
|
|
|
|
|
|
// Use majority currency if none specified
|
|
|
|
|
|
const currencyToUse = currency || this.getMajorityCurrency();
|
|
|
|
|
|
const symbol = this.getCurrencySymbol(currencyToUse);
|
|
|
|
|
|
|
2025-08-28 16:36:40 +01:00
|
|
|
|
const absAmount = Math.abs(amount);
|
|
|
|
|
|
if (absAmount >= 1000000) {
|
2025-09-03 14:37:57 +01:00
|
|
|
|
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000000).toFixed(2) + 'M';
|
2025-08-28 16:36:40 +01:00
|
|
|
|
} else if (absAmount >= 1000) {
|
2025-09-03 14:37:57 +01:00
|
|
|
|
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000).toFixed(1) + 'K';
|
2025-08-28 16:36:40 +01:00
|
|
|
|
} else {
|
2025-09-03 14:37:57 +01:00
|
|
|
|
return (amount >= 0 ? symbol : '-' + symbol) + absAmount.toFixed(2);
|
2025-08-28 16:36:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-28 19:20:36 +01:00
|
|
|
|
|
|
|
|
|
|
calculateAndDisplayCGT(etfMap, hasCurrentPrices) {
|
|
|
|
|
|
if (!this.cgtSettings || !this.cgtSettings.enabled || !hasCurrentPrices) {
|
|
|
|
|
|
document.getElementById('cgt-summary').style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let totalCGTLiability = 0;
|
|
|
|
|
|
let totalGainsBeforeTax = 0;
|
|
|
|
|
|
let totalAfterTaxGains = 0;
|
|
|
|
|
|
const allHoldingPeriods = {};
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate CGT for each ETF position
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
const cgtCalc = this.calculateCGTForPosition(symbol, etf);
|
|
|
|
|
|
totalCGTLiability += cgtCalc.totalCGT;
|
|
|
|
|
|
totalGainsBeforeTax += cgtCalc.totalGains;
|
|
|
|
|
|
totalAfterTaxGains += cgtCalc.afterTaxGains;
|
|
|
|
|
|
|
|
|
|
|
|
// Merge holding periods
|
|
|
|
|
|
Object.keys(cgtCalc.holdingPeriods).forEach(period => {
|
|
|
|
|
|
if (!allHoldingPeriods[period]) {
|
|
|
|
|
|
allHoldingPeriods[period] = { gains: 0, cgt: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
allHoldingPeriods[period].gains += cgtCalc.holdingPeriods[period].gains;
|
|
|
|
|
|
allHoldingPeriods[period].cgt += cgtCalc.holdingPeriods[period].cgt;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Apply annual exemption
|
|
|
|
|
|
const exemptionUsed = Math.min(totalGainsBeforeTax, this.cgtSettings.annual_exemption);
|
|
|
|
|
|
const taxableGains = Math.max(0, totalGainsBeforeTax - exemptionUsed);
|
|
|
|
|
|
const adjustedCGTLiability = Math.max(0, totalCGTLiability - (exemptionUsed * (totalCGTLiability / totalGainsBeforeTax)));
|
|
|
|
|
|
const finalAfterTaxGains = totalGainsBeforeTax - adjustedCGTLiability;
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveRate = totalGainsBeforeTax > 0 ? (adjustedCGTLiability / totalGainsBeforeTax) * 100 : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Update CGT summary display
|
|
|
|
|
|
document.getElementById('total-cgt-liability').textContent = this.formatCurrency(adjustedCGTLiability);
|
|
|
|
|
|
document.getElementById('cgt-effective-rate').textContent = `Effective rate: ${effectiveRate.toFixed(1)}%`;
|
|
|
|
|
|
document.getElementById('after-tax-gains').textContent = this.formatCurrency(finalAfterTaxGains);
|
|
|
|
|
|
document.getElementById('exemption-used').textContent = `Exemption: ${this.formatCurrency(exemptionUsed)} used`;
|
|
|
|
|
|
|
|
|
|
|
|
// Update holding periods summary
|
|
|
|
|
|
const holdingPeriodsEl = document.getElementById('holding-periods-summary');
|
|
|
|
|
|
const shortTermGains = (allHoldingPeriods['0-1 Month']?.gains || 0) +
|
|
|
|
|
|
(allHoldingPeriods['1-6 Months']?.gains || 0) +
|
|
|
|
|
|
(allHoldingPeriods['6M-1 Year']?.gains || 0);
|
|
|
|
|
|
const longTermGains = (allHoldingPeriods['1-2 Years']?.gains || 0) +
|
|
|
|
|
|
(allHoldingPeriods['2+ Years']?.gains || 0);
|
|
|
|
|
|
|
|
|
|
|
|
holdingPeriodsEl.innerHTML = `
|
|
|
|
|
|
<div class="period-item">
|
|
|
|
|
|
<span class="period-label">Short-term (<1Y):</span>
|
|
|
|
|
|
<span class="period-value">${this.formatCurrency(shortTermGains)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="period-item">
|
|
|
|
|
|
<span class="period-label">Long-term (1Y+):</span>
|
|
|
|
|
|
<span class="period-value">${this.formatCurrency(longTermGains)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
// Show/update CGT card colors
|
|
|
|
|
|
const cgtCards = document.querySelectorAll('.cgt-card');
|
|
|
|
|
|
cgtCards.forEach(card => {
|
|
|
|
|
|
card.classList.remove('positive', 'negative');
|
|
|
|
|
|
if (card.classList.contains('total-cgt')) {
|
|
|
|
|
|
card.classList.add('negative'); // Tax is always red
|
|
|
|
|
|
} else if (card.classList.contains('after-tax')) {
|
|
|
|
|
|
card.classList.add(finalAfterTaxGains >= 0 ? 'positive' : 'negative');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('cgt-summary').style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-29 10:39:27 +01:00
|
|
|
|
calculateAndDisplayLongTermCGT(etfMap, hasCurrentPrices) {
|
|
|
|
|
|
const longTermSection = document.getElementById('long-term-cgt-section');
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasCurrentPrices) {
|
|
|
|
|
|
longTermSection.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const eightYearsInDays = 8 * 365.25;
|
2025-08-29 19:54:29 +01:00
|
|
|
|
const cgtRate = (this.cgtSettings?.rate_8years || 33) / 100; // Rate from settings for 8+ years
|
2025-08-29 10:39:27 +01:00
|
|
|
|
|
|
|
|
|
|
let eligiblePositions = [];
|
|
|
|
|
|
let totalEligibleValue = 0;
|
|
|
|
|
|
let totalEligibleGains = 0;
|
|
|
|
|
|
let totalCost = 0;
|
|
|
|
|
|
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol);
|
|
|
|
|
|
if (!currentPrice) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Get all buy trades for this ETF that are 8+ years old
|
|
|
|
|
|
const longTermTrades = this.trades
|
|
|
|
|
|
.filter(t => t.etfSymbol === symbol && t.tradeType === 'buy')
|
|
|
|
|
|
.filter(t => {
|
|
|
|
|
|
const tradeDate = new Date(t.dateTime);
|
|
|
|
|
|
const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
return holdingDays >= eightYearsInDays;
|
|
|
|
|
|
})
|
|
|
|
|
|
.sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime));
|
|
|
|
|
|
|
|
|
|
|
|
if (longTermTrades.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
let positionShares = 0;
|
|
|
|
|
|
let positionCost = 0;
|
|
|
|
|
|
let oldestTradeDate = null;
|
|
|
|
|
|
|
|
|
|
|
|
longTermTrades.forEach(trade => {
|
|
|
|
|
|
positionShares += trade.shares;
|
|
|
|
|
|
positionCost += trade.totalValue;
|
|
|
|
|
|
if (!oldestTradeDate || new Date(trade.dateTime) < oldestTradeDate) {
|
|
|
|
|
|
oldestTradeDate = new Date(trade.dateTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const currentValue = positionShares * currentPrice;
|
|
|
|
|
|
const gains = currentValue - positionCost;
|
|
|
|
|
|
const holdingDays = Math.floor((now - oldestTradeDate) / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
const holdingYears = holdingDays / 365.25;
|
|
|
|
|
|
|
|
|
|
|
|
if (positionShares > 0) {
|
|
|
|
|
|
eligiblePositions.push({
|
|
|
|
|
|
symbol,
|
|
|
|
|
|
shares: positionShares,
|
|
|
|
|
|
cost: positionCost,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
gains,
|
|
|
|
|
|
currentPrice,
|
|
|
|
|
|
avgCost: positionCost / positionShares,
|
|
|
|
|
|
holdingYears,
|
|
|
|
|
|
currency: etf.currency
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
totalEligibleValue += currentValue;
|
|
|
|
|
|
totalEligibleGains += gains;
|
|
|
|
|
|
totalCost += positionCost;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (eligiblePositions.length === 0) {
|
|
|
|
|
|
longTermSection.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate CGT liability (only on gains)
|
|
|
|
|
|
const taxableGains = Math.max(0, totalEligibleGains);
|
|
|
|
|
|
const cgtLiability = taxableGains * cgtRate;
|
|
|
|
|
|
const afterTaxGains = totalEligibleGains - cgtLiability;
|
|
|
|
|
|
const gainsPercentage = totalCost > 0 ? ((totalEligibleGains / totalCost) * 100) : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Update display elements
|
|
|
|
|
|
document.getElementById('long-term-eligible-value').textContent = this.formatCurrency(totalEligibleValue);
|
|
|
|
|
|
document.getElementById('long-term-eligible-count').textContent = `${eligiblePositions.length} position${eligiblePositions.length === 1 ? '' : 's'}`;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('long-term-total-gains').textContent = this.formatCurrency(totalEligibleGains);
|
|
|
|
|
|
document.getElementById('long-term-gains-percentage').textContent = `${gainsPercentage >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}% gain`;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('long-term-cgt-liability').textContent = this.formatCurrency(cgtLiability);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('long-term-after-tax').textContent = this.formatCurrency(afterTaxGains);
|
|
|
|
|
|
document.getElementById('long-term-effective-rate').textContent = `Effective rate: ${cgtRate * 100}%`;
|
|
|
|
|
|
|
|
|
|
|
|
// Render breakdown
|
|
|
|
|
|
this.renderLongTermBreakdown(eligiblePositions);
|
|
|
|
|
|
|
|
|
|
|
|
longTermSection.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderLongTermBreakdown(eligiblePositions) {
|
|
|
|
|
|
const breakdownList = document.getElementById('long-term-breakdown-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (eligiblePositions.length === 0) {
|
|
|
|
|
|
breakdownList.innerHTML = '<p class="no-data">No holdings over 8 years</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const breakdownHTML = eligiblePositions.map(position => {
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(position.currency);
|
2025-08-29 10:39:27 +01:00
|
|
|
|
const gainsClass = position.gains >= 0 ? 'positive' : 'negative';
|
|
|
|
|
|
const performanceClass = position.gains >= 0 ? 'positive' : 'negative';
|
|
|
|
|
|
const gainsPercentage = position.cost > 0 ? ((position.gains / position.cost) * 100) : 0;
|
|
|
|
|
|
const cgtLiability = Math.max(0, position.gains) * 0.33;
|
|
|
|
|
|
const afterTaxGains = position.gains - cgtLiability;
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="long-term-breakdown-item ${gainsClass}">
|
|
|
|
|
|
<div class="long-term-breakdown-header">
|
|
|
|
|
|
<span class="long-term-symbol">${position.symbol}</span>
|
|
|
|
|
|
<span class="long-term-performance ${performanceClass}">
|
|
|
|
|
|
${position.gains >= 0 ? '+' : ''}${currencySymbol}${position.gains.toFixed(2)}
|
|
|
|
|
|
(${position.gains >= 0 ? '+' : ''}${gainsPercentage.toFixed(1)}%)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="long-term-details">
|
|
|
|
|
|
<div class="long-term-stat">
|
|
|
|
|
|
<span>Held</span>
|
|
|
|
|
|
<span>${position.holdingYears.toFixed(1)} years</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="long-term-stat">
|
|
|
|
|
|
<span>Shares</span>
|
|
|
|
|
|
<span>${position.shares.toFixed(3)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="long-term-stat">
|
|
|
|
|
|
<span>Avg Cost</span>
|
|
|
|
|
|
<span>${currencySymbol}${position.avgCost.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="long-term-stat">
|
|
|
|
|
|
<span>Current Price</span>
|
|
|
|
|
|
<span>${currencySymbol}${position.currentPrice.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="long-term-stat">
|
|
|
|
|
|
<span>CGT (33%)</span>
|
|
|
|
|
|
<span>-${currencySymbol}${cgtLiability.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="long-term-stat">
|
|
|
|
|
|
<span>After-Tax Gain</span>
|
|
|
|
|
|
<span class="${performanceClass}">${afterTaxGains >= 0 ? '+' : ''}${currencySymbol}${afterTaxGains.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
breakdownList.innerHTML = breakdownHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 19:20:36 +01:00
|
|
|
|
// CGT Settings and Calculation Methods
|
|
|
|
|
|
async loadCGTSettings() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cgt-settings`, {
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.cgtSettings = await response.json();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Use default settings if fetch fails
|
|
|
|
|
|
this.cgtSettings = {
|
|
|
|
|
|
rate_1month: 40.0,
|
|
|
|
|
|
rate_6months: 35.0,
|
|
|
|
|
|
rate_1year: 30.0,
|
|
|
|
|
|
rate_2years: 20.0,
|
|
|
|
|
|
rate_longterm: 10.0,
|
2025-08-29 19:54:29 +01:00
|
|
|
|
rate_8years: 33.0,
|
2025-08-28 19:20:36 +01:00
|
|
|
|
annual_exemption: 1270.0,
|
|
|
|
|
|
enabled: true
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading CGT settings:', error);
|
|
|
|
|
|
this.cgtSettings = {
|
|
|
|
|
|
rate_1month: 40.0,
|
|
|
|
|
|
rate_6months: 35.0,
|
|
|
|
|
|
rate_1year: 30.0,
|
|
|
|
|
|
rate_2years: 20.0,
|
|
|
|
|
|
rate_longterm: 10.0,
|
2025-08-29 19:54:29 +01:00
|
|
|
|
rate_8years: 33.0,
|
2025-08-28 19:20:36 +01:00
|
|
|
|
annual_exemption: 1270.0,
|
|
|
|
|
|
enabled: true
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async saveCGTSettings(settings) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cgt-settings`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(settings)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
this.cgtSettings = settings;
|
|
|
|
|
|
this.showNotification('CGT settings saved successfully!', 'success');
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh gains/losses calculations
|
|
|
|
|
|
if (document.getElementById('gains-losses-page').classList.contains('active')) {
|
|
|
|
|
|
this.renderGainsLossesPage();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh dashboard
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
this.showNotification(error.error || 'Failed to save CGT settings', 'error');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error saving CGT settings:', error);
|
|
|
|
|
|
this.showNotification('Failed to save CGT settings', 'error');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getCGTRateForHoldingPeriod(holdingDays) {
|
|
|
|
|
|
if (!this.cgtSettings || !this.cgtSettings.enabled) {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const holdingMonths = holdingDays / 30.44; // Average days per month
|
|
|
|
|
|
const holdingYears = holdingDays / 365.25; // Average days per year
|
|
|
|
|
|
|
|
|
|
|
|
if (holdingDays <= 30) {
|
|
|
|
|
|
return this.cgtSettings.rate_1month;
|
|
|
|
|
|
} else if (holdingMonths <= 6) {
|
|
|
|
|
|
return this.cgtSettings.rate_6months;
|
|
|
|
|
|
} else if (holdingYears <= 1) {
|
|
|
|
|
|
return this.cgtSettings.rate_1year;
|
|
|
|
|
|
} else if (holdingYears <= 2) {
|
|
|
|
|
|
return this.cgtSettings.rate_2years;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return this.cgtSettings.rate_longterm;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
calculateCGTForPosition(etfSymbol, etfData) {
|
|
|
|
|
|
if (!this.cgtSettings || !this.cgtSettings.enabled) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
totalCGT: 0,
|
|
|
|
|
|
afterTaxGains: etfData.totalGains || 0,
|
|
|
|
|
|
effectiveRate: 0,
|
|
|
|
|
|
holdingPeriods: {}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(etfSymbol);
|
|
|
|
|
|
if (!currentPrice) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
totalCGT: 0,
|
|
|
|
|
|
afterTaxGains: 0,
|
|
|
|
|
|
effectiveRate: 0,
|
|
|
|
|
|
holdingPeriods: {}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get all buy trades for this ETF, sorted by date (FIFO method)
|
|
|
|
|
|
const buyTrades = this.trades
|
|
|
|
|
|
.filter(t => t.etfSymbol === etfSymbol && t.tradeType === 'buy')
|
|
|
|
|
|
.sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime));
|
|
|
|
|
|
|
|
|
|
|
|
let totalCGT = 0;
|
|
|
|
|
|
let totalGains = 0;
|
|
|
|
|
|
let totalCost = 0;
|
|
|
|
|
|
const holdingPeriods = {};
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
|
|
buyTrades.forEach(trade => {
|
|
|
|
|
|
const tradeDate = new Date(trade.dateTime);
|
|
|
|
|
|
const holdingDays = Math.floor((now - tradeDate) / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
const currentValue = trade.shares * currentPrice;
|
|
|
|
|
|
const cost = trade.totalValue;
|
|
|
|
|
|
const gain = currentValue - cost;
|
|
|
|
|
|
|
|
|
|
|
|
if (gain > 0) {
|
|
|
|
|
|
const cgtRate = this.getCGTRateForHoldingPeriod(holdingDays);
|
|
|
|
|
|
const cgt = (gain * cgtRate) / 100;
|
|
|
|
|
|
|
|
|
|
|
|
totalCGT += cgt;
|
|
|
|
|
|
totalGains += gain;
|
|
|
|
|
|
totalCost += cost;
|
|
|
|
|
|
|
|
|
|
|
|
// Group by holding period for display
|
|
|
|
|
|
const period = this.getHoldingPeriodLabel(holdingDays);
|
|
|
|
|
|
if (!holdingPeriods[period]) {
|
|
|
|
|
|
holdingPeriods[period] = { gains: 0, cgt: 0, rate: cgtRate };
|
|
|
|
|
|
}
|
|
|
|
|
|
holdingPeriods[period].gains += gain;
|
|
|
|
|
|
holdingPeriods[period].cgt += cgt;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const afterTaxGains = totalGains - totalCGT;
|
|
|
|
|
|
const effectiveRate = totalGains > 0 ? (totalCGT / totalGains) * 100 : 0;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
totalCGT,
|
|
|
|
|
|
afterTaxGains,
|
|
|
|
|
|
effectiveRate,
|
|
|
|
|
|
holdingPeriods,
|
|
|
|
|
|
totalGains,
|
|
|
|
|
|
totalCost
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getHoldingPeriodLabel(holdingDays) {
|
|
|
|
|
|
const holdingMonths = holdingDays / 30.44;
|
|
|
|
|
|
const holdingYears = holdingDays / 365.25;
|
|
|
|
|
|
|
|
|
|
|
|
if (holdingDays <= 30) {
|
|
|
|
|
|
return '0-1 Month';
|
|
|
|
|
|
} else if (holdingMonths <= 6) {
|
|
|
|
|
|
return '1-6 Months';
|
|
|
|
|
|
} else if (holdingYears <= 1) {
|
|
|
|
|
|
return '6M-1 Year';
|
|
|
|
|
|
} else if (holdingYears <= 2) {
|
|
|
|
|
|
return '1-2 Years';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return '2+ Years';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderCGTSettingsPage() {
|
|
|
|
|
|
if (!this.cgtSettings) {
|
|
|
|
|
|
this.loadCGTSettings();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Populate form with current settings
|
|
|
|
|
|
document.getElementById('cgt-1month').value = this.cgtSettings.rate_1month;
|
|
|
|
|
|
document.getElementById('cgt-6months').value = this.cgtSettings.rate_6months;
|
|
|
|
|
|
document.getElementById('cgt-1year').value = this.cgtSettings.rate_1year;
|
|
|
|
|
|
document.getElementById('cgt-2years').value = this.cgtSettings.rate_2years;
|
|
|
|
|
|
document.getElementById('cgt-longterm').value = this.cgtSettings.rate_longterm;
|
2025-08-29 19:54:29 +01:00
|
|
|
|
document.getElementById('cgt-8years').value = this.cgtSettings.rate_8years;
|
2025-08-28 19:20:36 +01:00
|
|
|
|
document.getElementById('cgt-annual-exemption').value = this.cgtSettings.annual_exemption;
|
|
|
|
|
|
document.getElementById('cgt-enabled').checked = this.cgtSettings.enabled;
|
|
|
|
|
|
|
|
|
|
|
|
this.updateCGTPreview();
|
|
|
|
|
|
this.bindCGTEvents();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindCGTEvents() {
|
|
|
|
|
|
const form = document.getElementById('cgt-settings-form');
|
|
|
|
|
|
const resetBtn = document.getElementById('reset-cgt-defaults');
|
|
|
|
|
|
const inputs = document.querySelectorAll('.cgt-rate-input');
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing listeners to prevent duplicates
|
|
|
|
|
|
form.removeEventListener('submit', this.handleCGTFormSubmit);
|
|
|
|
|
|
resetBtn.removeEventListener('click', this.handleCGTReset);
|
|
|
|
|
|
|
|
|
|
|
|
// Bind form submission
|
|
|
|
|
|
this.handleCGTFormSubmit = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const settings = {
|
|
|
|
|
|
rate_1month: parseFloat(document.getElementById('cgt-1month').value),
|
|
|
|
|
|
rate_6months: parseFloat(document.getElementById('cgt-6months').value),
|
|
|
|
|
|
rate_1year: parseFloat(document.getElementById('cgt-1year').value),
|
|
|
|
|
|
rate_2years: parseFloat(document.getElementById('cgt-2years').value),
|
|
|
|
|
|
rate_longterm: parseFloat(document.getElementById('cgt-longterm').value),
|
2025-08-29 19:54:29 +01:00
|
|
|
|
rate_8years: parseFloat(document.getElementById('cgt-8years').value),
|
2025-08-28 19:20:36 +01:00
|
|
|
|
annual_exemption: parseFloat(document.getElementById('cgt-annual-exemption').value),
|
|
|
|
|
|
enabled: document.getElementById('cgt-enabled').checked
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await this.saveCGTSettings(settings);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bind reset button
|
|
|
|
|
|
this.handleCGTReset = () => {
|
|
|
|
|
|
document.getElementById('cgt-1month').value = 40;
|
|
|
|
|
|
document.getElementById('cgt-6months').value = 35;
|
|
|
|
|
|
document.getElementById('cgt-1year').value = 30;
|
|
|
|
|
|
document.getElementById('cgt-2years').value = 20;
|
|
|
|
|
|
document.getElementById('cgt-longterm').value = 10;
|
2025-08-29 19:54:29 +01:00
|
|
|
|
document.getElementById('cgt-8years').value = 33;
|
2025-08-28 19:20:36 +01:00
|
|
|
|
document.getElementById('cgt-annual-exemption').value = 1270;
|
|
|
|
|
|
document.getElementById('cgt-enabled').checked = true;
|
|
|
|
|
|
this.updateCGTPreview();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
form.addEventListener('submit', this.handleCGTFormSubmit);
|
|
|
|
|
|
resetBtn.addEventListener('click', this.handleCGTReset);
|
|
|
|
|
|
|
|
|
|
|
|
// Update preview on input change
|
|
|
|
|
|
inputs.forEach(input => {
|
|
|
|
|
|
input.addEventListener('input', () => this.updateCGTPreview());
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateCGTPreview() {
|
|
|
|
|
|
const rates = {
|
|
|
|
|
|
'1month': parseFloat(document.getElementById('cgt-1month').value) || 0,
|
|
|
|
|
|
'6months': parseFloat(document.getElementById('cgt-6months').value) || 0,
|
|
|
|
|
|
'1year': parseFloat(document.getElementById('cgt-1year').value) || 0,
|
|
|
|
|
|
'2years': parseFloat(document.getElementById('cgt-2years').value) || 0,
|
2025-08-29 19:54:29 +01:00
|
|
|
|
'longterm': parseFloat(document.getElementById('cgt-longterm').value) || 0,
|
|
|
|
|
|
'8years': parseFloat(document.getElementById('cgt-8years').value) || 0
|
2025-08-28 19:20:36 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Object.keys(rates).forEach(period => {
|
|
|
|
|
|
const rateBar = document.querySelector(`[data-period="${period}"]`);
|
|
|
|
|
|
if (rateBar) {
|
|
|
|
|
|
const rateValue = rateBar.querySelector('.rate-value');
|
|
|
|
|
|
rateValue.textContent = `${rates[period]}%`;
|
|
|
|
|
|
|
|
|
|
|
|
// Update bar height based on rate (max 50% height)
|
|
|
|
|
|
const height = Math.max(10, (rates[period] / 100) * 50);
|
|
|
|
|
|
rateBar.style.setProperty('--rate-height', `${height}%`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-08-29 19:54:29 +01:00
|
|
|
|
|
|
|
|
|
|
// Cash Accounts Management Methods
|
|
|
|
|
|
async renderCashAccountsPage() {
|
|
|
|
|
|
await this.loadCashAccounts();
|
|
|
|
|
|
await this.loadCashSummary();
|
|
|
|
|
|
await this.renderTransfersSection();
|
|
|
|
|
|
this.bindCashAccountEvents();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadCashAccounts() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const accounts = await response.json();
|
|
|
|
|
|
this.renderAccountsList(accounts);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Failed to load cash accounts', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading cash accounts:', error);
|
|
|
|
|
|
this.showNotification('Failed to load cash accounts', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadCashSummary() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-summary`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const summary = await response.json();
|
|
|
|
|
|
this.updateCashSummary(summary);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to load cash summary');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading cash summary:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateCashSummary(summary) {
|
|
|
|
|
|
document.getElementById('total-cash-eur').textContent = `€${summary.total_eur.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
document.getElementById('total-cash-usd').textContent = `$${summary.total_usd.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
document.getElementById('account-count').textContent = summary.account_count;
|
|
|
|
|
|
document.getElementById('avg-interest').textContent = `${summary.avg_interest_rate.toFixed(1)}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderAccountsList(accounts) {
|
|
|
|
|
|
const accountsList = document.getElementById('accounts-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (!accounts || accounts.length === 0) {
|
|
|
|
|
|
accountsList.innerHTML = '<p class="no-data">No cash accounts found. Add your first account above.</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
accountsList.innerHTML = accounts.map(account => {
|
|
|
|
|
|
const createdDate = new Date(account.created_at).toLocaleDateString();
|
|
|
|
|
|
const balance = parseFloat(account.balance);
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(account.currency);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
const formattedBalance = `${currencySymbol}${balance.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
|
|
|
|
|
|
|
|
|
|
|
const accountTypeMap = {
|
|
|
|
|
|
savings: 'Savings Account',
|
|
|
|
|
|
checking: 'Checking Account',
|
|
|
|
|
|
money_market: 'Money Market',
|
|
|
|
|
|
cd: 'Certificate of Deposit',
|
|
|
|
|
|
other: 'Other'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="account-item" data-account-id="${account.id}">
|
|
|
|
|
|
<div class="account-info">
|
|
|
|
|
|
<div class="account-name">${this.escapeHtml(account.account_name)}</div>
|
|
|
|
|
|
<div class="account-type-badge">${accountTypeMap[account.account_type] || 'Other'}</div>
|
|
|
|
|
|
<div class="account-details">
|
|
|
|
|
|
<div class="account-detail">
|
|
|
|
|
|
<span class="account-detail-label">Balance</span>
|
|
|
|
|
|
<span class="account-balance">${formattedBalance}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${account.institution_name ? `
|
|
|
|
|
|
<div class="account-detail">
|
|
|
|
|
|
<span class="account-detail-label">Institution</span>
|
|
|
|
|
|
<span>${this.escapeHtml(account.institution_name)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
${account.interest_rate > 0 ? `
|
|
|
|
|
|
<div class="account-detail">
|
|
|
|
|
|
<span class="account-detail-label">Interest Rate</span>
|
|
|
|
|
|
<span>${account.interest_rate}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
<div class="account-detail">
|
|
|
|
|
|
<span class="account-detail-label">Created</span>
|
|
|
|
|
|
<span>${createdDate}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${account.notes ? `
|
|
|
|
|
|
<div class="account-detail">
|
|
|
|
|
|
<span class="account-detail-label">Notes</span>
|
|
|
|
|
|
<span>${this.escapeHtml(account.notes)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="account-actions-buttons">
|
|
|
|
|
|
<button class="account-edit-btn" data-account-id="${account.id}">Edit</button>
|
|
|
|
|
|
<button class="account-delete-btn" data-account-id="${account.id}">Delete</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindCashAccountEvents() {
|
|
|
|
|
|
const createAccountForm = document.getElementById('create-account-form');
|
|
|
|
|
|
const editAccountForm = document.getElementById('edit-account-form');
|
|
|
|
|
|
const editModal = document.getElementById('edit-account-modal');
|
|
|
|
|
|
const cancelEditBtn = document.getElementById('cancel-edit-account');
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing listeners
|
|
|
|
|
|
createAccountForm.removeEventListener('submit', this.handleCreateAccount);
|
|
|
|
|
|
editAccountForm.removeEventListener('submit', this.handleEditAccount);
|
|
|
|
|
|
cancelEditBtn.removeEventListener('click', this.handleCancelEdit);
|
|
|
|
|
|
|
|
|
|
|
|
// Create account form
|
|
|
|
|
|
this.handleCreateAccount = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const formData = {
|
|
|
|
|
|
account_name: document.getElementById('account-name').value.trim(),
|
|
|
|
|
|
account_type: document.getElementById('account-type').value,
|
|
|
|
|
|
balance: parseFloat(document.getElementById('account-balance').value) || 0,
|
|
|
|
|
|
currency: document.getElementById('account-currency').value,
|
|
|
|
|
|
institution_name: document.getElementById('institution-name').value.trim(),
|
|
|
|
|
|
interest_rate: parseFloat(document.getElementById('interest-rate').value) || 0,
|
|
|
|
|
|
notes: document.getElementById('account-notes').value.trim()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(formData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
createAccountForm.reset();
|
|
|
|
|
|
await this.loadCashAccounts();
|
|
|
|
|
|
await this.loadCashSummary();
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard cash data
|
|
|
|
|
|
this.showNotification('Cash account created successfully!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to create account', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error creating account:', error);
|
|
|
|
|
|
this.showNotification('Failed to create account', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Edit account form
|
|
|
|
|
|
this.handleEditAccount = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const accountId = editAccountForm.getAttribute('data-account-id');
|
|
|
|
|
|
const formData = {
|
|
|
|
|
|
account_name: document.getElementById('edit-account-name').value.trim(),
|
|
|
|
|
|
account_type: document.getElementById('edit-account-type').value,
|
|
|
|
|
|
balance: parseFloat(document.getElementById('edit-account-balance').value) || 0,
|
|
|
|
|
|
currency: document.getElementById('edit-account-currency').value,
|
|
|
|
|
|
institution_name: document.getElementById('edit-institution-name').value.trim(),
|
|
|
|
|
|
interest_rate: parseFloat(document.getElementById('edit-interest-rate').value) || 0,
|
|
|
|
|
|
notes: document.getElementById('edit-account-notes').value.trim()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts/${accountId}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(formData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
editModal.style.display = 'none';
|
|
|
|
|
|
await this.loadCashAccounts();
|
|
|
|
|
|
await this.loadCashSummary();
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard cash data
|
|
|
|
|
|
this.showNotification('Cash account updated successfully!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to update account', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating account:', error);
|
|
|
|
|
|
this.showNotification('Failed to update account', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Cancel edit
|
|
|
|
|
|
this.handleCancelEdit = () => {
|
|
|
|
|
|
editModal.style.display = 'none';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bind events
|
|
|
|
|
|
createAccountForm.addEventListener('submit', this.handleCreateAccount);
|
|
|
|
|
|
editAccountForm.addEventListener('submit', this.handleEditAccount);
|
|
|
|
|
|
cancelEditBtn.addEventListener('click', this.handleCancelEdit);
|
|
|
|
|
|
|
|
|
|
|
|
// Bind account action buttons
|
|
|
|
|
|
document.querySelectorAll('.account-delete-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', this.handleDeleteAccount.bind(this));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.account-edit-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', this.handleEditAccountClick.bind(this));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Close modal on backdrop click
|
|
|
|
|
|
editModal.addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target === editModal) {
|
|
|
|
|
|
this.handleCancelEdit();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleDeleteAccount(e) {
|
|
|
|
|
|
const accountId = e.target.getAttribute('data-account-id');
|
|
|
|
|
|
const accountName = e.target.closest('.account-item').querySelector('.account-name').textContent;
|
|
|
|
|
|
|
|
|
|
|
|
if (!confirm(`Are you sure you want to delete the account "${accountName}"? This action cannot be undone.`)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts/${accountId}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
await this.loadCashAccounts();
|
|
|
|
|
|
await this.loadCashSummary();
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard cash data
|
|
|
|
|
|
this.showNotification('Cash account deleted successfully', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to delete account', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error deleting account:', error);
|
|
|
|
|
|
this.showNotification('Failed to delete account', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleEditAccountClick(e) {
|
|
|
|
|
|
const accountId = e.target.getAttribute('data-account-id');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const accounts = await response.json();
|
|
|
|
|
|
const account = accounts.find(acc => acc.id == accountId);
|
|
|
|
|
|
|
|
|
|
|
|
if (account) {
|
|
|
|
|
|
// Populate edit form
|
|
|
|
|
|
document.getElementById('edit-account-name').value = account.account_name;
|
|
|
|
|
|
document.getElementById('edit-account-type').value = account.account_type;
|
|
|
|
|
|
document.getElementById('edit-account-balance').value = account.balance;
|
|
|
|
|
|
document.getElementById('edit-account-currency').value = account.currency;
|
|
|
|
|
|
document.getElementById('edit-institution-name').value = account.institution_name || '';
|
|
|
|
|
|
document.getElementById('edit-interest-rate').value = account.interest_rate || '';
|
|
|
|
|
|
document.getElementById('edit-account-notes').value = account.notes || '';
|
|
|
|
|
|
|
|
|
|
|
|
// Set account ID for form submission
|
|
|
|
|
|
document.getElementById('edit-account-form').setAttribute('data-account-id', accountId);
|
|
|
|
|
|
|
|
|
|
|
|
// Show modal
|
|
|
|
|
|
document.getElementById('edit-account-modal').style.display = 'flex';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading account for edit:', error);
|
|
|
|
|
|
this.showNotification('Failed to load account details', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cash Transfers Methods
|
|
|
|
|
|
async renderTransfersSection() {
|
|
|
|
|
|
await this.loadTransfers();
|
|
|
|
|
|
this.bindTransferEvents();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadTransfers() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const transfers = await response.json();
|
|
|
|
|
|
this.renderTransfersList(transfers);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Failed to load transfers', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading transfers:', error);
|
|
|
|
|
|
this.showNotification('Failed to load transfers', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderTransfersList(transfers) {
|
|
|
|
|
|
const transfersList = document.getElementById('transfers-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (!transfers || transfers.length === 0) {
|
|
|
|
|
|
transfersList.innerHTML = '<p class="no-data">No transfers found</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
transfersList.innerHTML = transfers.map(transfer => {
|
|
|
|
|
|
const transferDate = new Date(transfer.transfer_date).toLocaleDateString();
|
|
|
|
|
|
const amount = parseFloat(transfer.amount);
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(transfer.currency);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="transfer-item">
|
|
|
|
|
|
<div class="transfer-info">
|
|
|
|
|
|
<div class="transfer-header">
|
|
|
|
|
|
<span class="transfer-account">${transfer.account_name}</span>
|
|
|
|
|
|
<span class="transfer-type ${transfer.transfer_type}">${transfer.transfer_type}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="transfer-details">
|
|
|
|
|
|
<span class="transfer-date">${transferDate}</span>
|
|
|
|
|
|
${transfer.description ? `<span class="transfer-description">${transfer.description}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="transfer-amount ${transfer.transfer_type}">
|
|
|
|
|
|
${currencySymbol}${amount.toFixed(2)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async populateTransferAccounts() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const accounts = await response.json();
|
|
|
|
|
|
const accountSelect = document.getElementById('transfer-account');
|
|
|
|
|
|
|
|
|
|
|
|
// Clear existing options except the first one
|
|
|
|
|
|
accountSelect.innerHTML = '<option value="">Select Account</option>';
|
|
|
|
|
|
|
|
|
|
|
|
// Add active accounts
|
|
|
|
|
|
const activeAccounts = accounts.filter(account => account.is_active);
|
|
|
|
|
|
activeAccounts.forEach(account => {
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(account.currency);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
|
option.value = account.id;
|
|
|
|
|
|
option.textContent = `${account.account_name} (${currencySymbol}${parseFloat(account.balance).toFixed(2)})`;
|
|
|
|
|
|
accountSelect.appendChild(option);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading accounts for transfer:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindTransferEvents() {
|
|
|
|
|
|
const addTransferBtn = document.getElementById('add-transfer-btn');
|
|
|
|
|
|
const transferModal = document.getElementById('transfer-modal');
|
|
|
|
|
|
const transferForm = document.getElementById('transfer-form');
|
|
|
|
|
|
const cancelTransferBtn = document.getElementById('cancel-transfer');
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing listeners
|
|
|
|
|
|
addTransferBtn.removeEventListener('click', this.handleShowTransferModal);
|
|
|
|
|
|
transferForm.removeEventListener('submit', this.handleCreateTransfer);
|
|
|
|
|
|
cancelTransferBtn.removeEventListener('click', this.handleCancelTransfer);
|
|
|
|
|
|
|
|
|
|
|
|
// Add transfer button
|
|
|
|
|
|
this.handleShowTransferModal = async () => {
|
|
|
|
|
|
await this.populateTransferAccounts();
|
|
|
|
|
|
// Set today's date as default
|
|
|
|
|
|
document.getElementById('transfer-date').value = new Date().toISOString().split('T')[0];
|
|
|
|
|
|
transferModal.style.display = 'flex';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Create transfer form
|
|
|
|
|
|
this.handleCreateTransfer = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const formData = {
|
|
|
|
|
|
account_id: parseInt(document.getElementById('transfer-account').value),
|
|
|
|
|
|
transfer_type: document.getElementById('transfer-type').value,
|
|
|
|
|
|
amount: parseFloat(document.getElementById('transfer-amount').value),
|
|
|
|
|
|
transfer_date: document.getElementById('transfer-date').value,
|
|
|
|
|
|
description: document.getElementById('transfer-description').value.trim()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(formData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
transferModal.style.display = 'none';
|
|
|
|
|
|
transferForm.reset();
|
|
|
|
|
|
await this.loadTransfers();
|
|
|
|
|
|
await this.loadCashAccounts();
|
|
|
|
|
|
await this.loadCashSummary();
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard cash data
|
|
|
|
|
|
this.showNotification('Transfer added successfully!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to add transfer', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error creating transfer:', error);
|
|
|
|
|
|
this.showNotification('Failed to add transfer', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Cancel transfer
|
|
|
|
|
|
this.handleCancelTransfer = () => {
|
|
|
|
|
|
transferModal.style.display = 'none';
|
|
|
|
|
|
transferForm.reset();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bind events
|
|
|
|
|
|
addTransferBtn.addEventListener('click', this.handleShowTransferModal);
|
|
|
|
|
|
transferForm.addEventListener('submit', this.handleCreateTransfer);
|
|
|
|
|
|
cancelTransferBtn.addEventListener('click', this.handleCancelTransfer);
|
|
|
|
|
|
|
|
|
|
|
|
// Close modal when clicking outside
|
|
|
|
|
|
transferModal.addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target === transferModal) {
|
|
|
|
|
|
this.handleCancelTransfer();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Standalone Add Transfer Page Methods
|
|
|
|
|
|
async renderAddTransferPage() {
|
|
|
|
|
|
await this.populateStandaloneTransferAccounts();
|
|
|
|
|
|
await this.loadStandaloneTransfers();
|
|
|
|
|
|
this.bindStandaloneTransferEvents();
|
|
|
|
|
|
|
|
|
|
|
|
// Set today's date as default
|
|
|
|
|
|
document.getElementById('standalone-transfer-date').value = new Date().toISOString().split('T')[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async populateStandaloneTransferAccounts() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-accounts`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const accounts = await response.json();
|
|
|
|
|
|
const accountSelect = document.getElementById('standalone-transfer-account');
|
|
|
|
|
|
|
|
|
|
|
|
// Clear existing options except the first one
|
|
|
|
|
|
accountSelect.innerHTML = '<option value="">Select Account</option>';
|
|
|
|
|
|
|
|
|
|
|
|
// Add active accounts
|
|
|
|
|
|
const activeAccounts = accounts.filter(account => account.is_active);
|
|
|
|
|
|
activeAccounts.forEach(account => {
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(account.currency);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
|
option.value = account.id;
|
|
|
|
|
|
option.textContent = `${account.account_name} (${currencySymbol}${parseFloat(account.balance).toFixed(2)})`;
|
|
|
|
|
|
accountSelect.appendChild(option);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading accounts for transfer:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadStandaloneTransfers() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const transfers = await response.json();
|
|
|
|
|
|
this.renderStandaloneTransfersList(transfers);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Failed to load transfers', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading transfers:', error);
|
|
|
|
|
|
this.showNotification('Failed to load transfers', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderStandaloneTransfersList(transfers) {
|
|
|
|
|
|
const transfersList = document.getElementById('standalone-transfers-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (!transfers || transfers.length === 0) {
|
|
|
|
|
|
transfersList.innerHTML = '<p class="no-data">No transfers found</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Show only the last 10 transfers for the standalone page
|
|
|
|
|
|
const recentTransfers = transfers.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
transfersList.innerHTML = recentTransfers.map(transfer => {
|
|
|
|
|
|
const transferDate = new Date(transfer.transfer_date).toLocaleDateString();
|
|
|
|
|
|
const amount = parseFloat(transfer.amount);
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(transfer.currency);
|
2025-08-29 19:54:29 +01:00
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="transfer-item">
|
|
|
|
|
|
<div class="transfer-info">
|
|
|
|
|
|
<div class="transfer-header">
|
|
|
|
|
|
<span class="transfer-account">${transfer.account_name}</span>
|
|
|
|
|
|
<span class="transfer-type ${transfer.transfer_type}">${transfer.transfer_type}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="transfer-details">
|
|
|
|
|
|
<span class="transfer-date">${transferDate}</span>
|
|
|
|
|
|
${transfer.description ? `<span class="transfer-description">${transfer.description}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="transfer-amount ${transfer.transfer_type}">
|
|
|
|
|
|
${currencySymbol}${amount.toFixed(2)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindStandaloneTransferEvents() {
|
|
|
|
|
|
const transferForm = document.getElementById('standalone-transfer-form');
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing listener
|
|
|
|
|
|
transferForm.removeEventListener('submit', this.handleStandaloneTransfer);
|
|
|
|
|
|
|
|
|
|
|
|
// Create transfer form handler
|
|
|
|
|
|
this.handleStandaloneTransfer = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const formData = {
|
|
|
|
|
|
account_id: parseInt(document.getElementById('standalone-transfer-account').value),
|
|
|
|
|
|
transfer_type: document.getElementById('standalone-transfer-type').value,
|
|
|
|
|
|
amount: parseFloat(document.getElementById('standalone-transfer-amount').value),
|
|
|
|
|
|
transfer_date: document.getElementById('standalone-transfer-date').value,
|
|
|
|
|
|
description: document.getElementById('standalone-transfer-description').value.trim()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/cash-transfers`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(formData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
transferForm.reset();
|
|
|
|
|
|
// Set today's date as default again
|
|
|
|
|
|
document.getElementById('standalone-transfer-date').value = new Date().toISOString().split('T')[0];
|
|
|
|
|
|
|
|
|
|
|
|
await this.loadStandaloneTransfers();
|
|
|
|
|
|
await this.populateStandaloneTransferAccounts(); // Refresh account balances
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard cash data
|
|
|
|
|
|
this.showNotification('Transfer added successfully!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to add transfer', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error creating transfer:', error);
|
|
|
|
|
|
this.showNotification('Failed to add transfer', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bind form submission
|
|
|
|
|
|
transferForm.addEventListener('submit', this.handleStandaloneTransfer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Token Management Methods
|
|
|
|
|
|
async renderTokensPage() {
|
|
|
|
|
|
await this.loadTokens();
|
|
|
|
|
|
this.bindTokenEvents();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadTokens() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/tokens`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const tokens = await response.json();
|
|
|
|
|
|
this.renderTokensList(tokens);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification('Failed to load tokens', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading tokens:', error);
|
|
|
|
|
|
this.showNotification('Failed to load tokens', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderTokensList(tokens) {
|
|
|
|
|
|
const tokensList = document.getElementById('tokens-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (!tokens || tokens.length === 0) {
|
|
|
|
|
|
tokensList.innerHTML = '<p class="no-data">No access tokens found. Create your first token above.</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokensList.innerHTML = tokens.map(token => {
|
|
|
|
|
|
const createdDate = new Date(token.created_at).toLocaleDateString();
|
|
|
|
|
|
const lastUsed = token.last_used_at ? new Date(token.last_used_at).toLocaleDateString() : 'Never';
|
|
|
|
|
|
const expiresAt = token.expires_at ? new Date(token.expires_at).toLocaleDateString() : 'Never';
|
|
|
|
|
|
const isExpired = token.expires_at && new Date(token.expires_at) < new Date();
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="token-item" data-token-id="${token.id}">
|
|
|
|
|
|
<div class="token-info">
|
|
|
|
|
|
<div class="token-name">${this.escapeHtml(token.token_name)}</div>
|
|
|
|
|
|
<div class="token-details">
|
|
|
|
|
|
<div class="token-detail">
|
|
|
|
|
|
<span>Token:</span>
|
|
|
|
|
|
<span class="token-prefix">${token.token_prefix}•••••••</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="token-detail">
|
|
|
|
|
|
<span>Scopes:</span>
|
|
|
|
|
|
<span>${token.scopes}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="token-detail">
|
|
|
|
|
|
<span>Created:</span>
|
|
|
|
|
|
<span>${createdDate}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="token-detail">
|
|
|
|
|
|
<span>Last used:</span>
|
|
|
|
|
|
<span>${lastUsed}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="token-detail">
|
|
|
|
|
|
<span>Expires:</span>
|
|
|
|
|
|
<span>${expiresAt}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="token-detail">
|
|
|
|
|
|
<span class="token-status ${isExpired ? 'expired' : 'active'}">
|
|
|
|
|
|
${isExpired ? 'Expired' : 'Active'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="token-actions-buttons">
|
|
|
|
|
|
<button class="token-edit-btn" data-token-id="${token.id}">Edit</button>
|
|
|
|
|
|
<button class="token-delete-btn" data-token-id="${token.id}">Delete</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindTokenEvents() {
|
|
|
|
|
|
const createTokenForm = document.getElementById('create-token-form');
|
|
|
|
|
|
const copyTokenBtn = document.getElementById('copy-token-btn');
|
|
|
|
|
|
const closeModalBtn = document.getElementById('close-token-modal');
|
|
|
|
|
|
const tokenModal = document.getElementById('token-modal');
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing listeners
|
|
|
|
|
|
createTokenForm.removeEventListener('submit', this.handleCreateToken);
|
|
|
|
|
|
copyTokenBtn.removeEventListener('click', this.handleCopyToken);
|
|
|
|
|
|
closeModalBtn.removeEventListener('click', this.handleCloseTokenModal);
|
|
|
|
|
|
|
|
|
|
|
|
// Create token form
|
|
|
|
|
|
this.handleCreateToken = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const formData = {
|
|
|
|
|
|
name: document.getElementById('token-name').value.trim(),
|
|
|
|
|
|
expires_in_days: document.getElementById('token-expires').value || null,
|
|
|
|
|
|
scopes: document.getElementById('token-scopes').value
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/tokens`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(formData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
document.getElementById('new-token-value').value = result.token;
|
|
|
|
|
|
tokenModal.style.display = 'flex';
|
|
|
|
|
|
createTokenForm.reset();
|
|
|
|
|
|
await this.loadTokens();
|
|
|
|
|
|
this.showNotification('Token created successfully!', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to create token', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error creating token:', error);
|
|
|
|
|
|
this.showNotification('Failed to create token', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Copy token to clipboard
|
|
|
|
|
|
this.handleCopyToken = async () => {
|
|
|
|
|
|
const tokenInput = document.getElementById('new-token-value');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(tokenInput.value);
|
|
|
|
|
|
copyTokenBtn.textContent = 'Copied!';
|
|
|
|
|
|
copyTokenBtn.classList.add('copied');
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
copyTokenBtn.textContent = 'Copy';
|
|
|
|
|
|
copyTokenBtn.classList.remove('copied');
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Fallback for older browsers
|
|
|
|
|
|
tokenInput.select();
|
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
|
copyTokenBtn.textContent = 'Copied!';
|
|
|
|
|
|
copyTokenBtn.classList.add('copied');
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
copyTokenBtn.textContent = 'Copy';
|
|
|
|
|
|
copyTokenBtn.classList.remove('copied');
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Close modal
|
|
|
|
|
|
this.handleCloseTokenModal = () => {
|
|
|
|
|
|
tokenModal.style.display = 'none';
|
|
|
|
|
|
document.getElementById('new-token-value').value = '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bind events
|
|
|
|
|
|
createTokenForm.addEventListener('submit', this.handleCreateToken);
|
|
|
|
|
|
copyTokenBtn.addEventListener('click', this.handleCopyToken);
|
|
|
|
|
|
closeModalBtn.addEventListener('click', this.handleCloseTokenModal);
|
|
|
|
|
|
|
|
|
|
|
|
// Bind token action buttons
|
|
|
|
|
|
document.querySelectorAll('.token-delete-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', this.handleDeleteToken.bind(this));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.token-edit-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', this.handleEditToken.bind(this));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Close modal on backdrop click
|
|
|
|
|
|
tokenModal.addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target === tokenModal) {
|
|
|
|
|
|
this.handleCloseTokenModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleDeleteToken(e) {
|
|
|
|
|
|
const tokenId = e.target.getAttribute('data-token-id');
|
|
|
|
|
|
const tokenName = e.target.closest('.token-item').querySelector('.token-name').textContent;
|
|
|
|
|
|
|
|
|
|
|
|
if (!confirm(`Are you sure you want to delete the token "${tokenName}"? This action cannot be undone.`)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/tokens/${tokenId}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
await this.loadTokens();
|
|
|
|
|
|
this.showNotification('Token deleted successfully', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to delete token', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error deleting token:', error);
|
|
|
|
|
|
this.showNotification('Failed to delete token', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleEditToken(e) {
|
|
|
|
|
|
const tokenId = e.target.getAttribute('data-token-id');
|
|
|
|
|
|
const tokenItem = e.target.closest('.token-item');
|
|
|
|
|
|
const tokenName = tokenItem.querySelector('.token-name').textContent;
|
|
|
|
|
|
|
|
|
|
|
|
const newName = prompt('Enter new token name:', tokenName);
|
|
|
|
|
|
if (!newName || newName.trim() === tokenName) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/tokens/${tokenId}`, {
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify({ name: newName.trim() })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
await this.loadTokens();
|
|
|
|
|
|
this.showNotification('Token updated successfully', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
this.showNotification(result.error || 'Failed to update token', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating token:', error);
|
|
|
|
|
|
this.showNotification('Failed to update token', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-01 16:42:00 +01:00
|
|
|
|
async showPriceHistory(symbol) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const history = await this.loadPriceHistory(symbol);
|
|
|
|
|
|
|
|
|
|
|
|
if (history.length === 0) {
|
|
|
|
|
|
this.showNotification(`No price history found for ${symbol}`, 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create modal for price history
|
|
|
|
|
|
this.createPriceHistoryModal(symbol, history);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error showing price history:', error);
|
|
|
|
|
|
this.showNotification('Failed to load price history', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
createPriceHistoryModal(symbol, history) {
|
|
|
|
|
|
// Remove existing modal if any
|
|
|
|
|
|
const existingModal = document.getElementById('price-history-modal');
|
|
|
|
|
|
if (existingModal) {
|
|
|
|
|
|
existingModal.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-01 18:37:41 +01:00
|
|
|
|
const currencySymbol = this.getCurrencySymbol(history[0]?.currency);
|
2025-09-01 16:42:00 +01:00
|
|
|
|
|
|
|
|
|
|
const modal = document.createElement('div');
|
|
|
|
|
|
modal.id = 'price-history-modal';
|
|
|
|
|
|
modal.className = 'modal';
|
|
|
|
|
|
modal.innerHTML = `
|
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<h3>Price History - ${symbol}</h3>
|
|
|
|
|
|
<span class="close-modal">×</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<div class="price-history-list">
|
|
|
|
|
|
${history.map(entry => `
|
|
|
|
|
|
<div class="price-history-entry">
|
|
|
|
|
|
<span class="history-price">${currencySymbol}${parseFloat(entry.price).toFixed(2)}</span>
|
|
|
|
|
|
<span class="history-date">${new Date(entry.updatedAt).toLocaleString()}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
|
|
modal.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
|
|
// Close modal events
|
|
|
|
|
|
const closeBtn = modal.querySelector('.close-modal');
|
|
|
|
|
|
closeBtn.addEventListener('click', () => this.closePriceHistoryModal());
|
|
|
|
|
|
|
|
|
|
|
|
modal.addEventListener('click', (e) => {
|
|
|
|
|
|
if (e.target === modal) {
|
|
|
|
|
|
this.closePriceHistoryModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Close on Escape key
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
this.closePriceHistoryModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
closePriceHistoryModal() {
|
|
|
|
|
|
const modal = document.getElementById('price-history-modal');
|
|
|
|
|
|
if (modal) {
|
|
|
|
|
|
modal.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-01 18:37:41 +01:00
|
|
|
|
getCurrencySymbol(currency) {
|
|
|
|
|
|
switch(currency) {
|
|
|
|
|
|
case 'EUR': return '€';
|
|
|
|
|
|
case 'USD': return '$';
|
|
|
|
|
|
case 'GBP': return '£';
|
|
|
|
|
|
default: return currency;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-03 14:37:57 +01:00
|
|
|
|
getMajorityCurrency() {
|
|
|
|
|
|
const currencyValues = {
|
|
|
|
|
|
EUR: 0,
|
|
|
|
|
|
USD: 0,
|
|
|
|
|
|
GBP: 0
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate total value by currency from ETF positions
|
|
|
|
|
|
const etfMap = this.getActiveETFPositions();
|
|
|
|
|
|
etfMap.forEach((etf, symbol) => {
|
|
|
|
|
|
const currentPrice = this.currentPrices.get(symbol);
|
|
|
|
|
|
const value = currentPrice ? etf.shares * currentPrice : etf.totalValue;
|
|
|
|
|
|
currencyValues[etf.currency] = (currencyValues[etf.currency] || 0) + value;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Add cash accounts to the calculation
|
|
|
|
|
|
if (this.cashSummary && this.cashSummary.accounts) {
|
|
|
|
|
|
this.cashSummary.accounts.forEach(account => {
|
|
|
|
|
|
if (account.is_active && account.balance > 0) {
|
|
|
|
|
|
currencyValues[account.currency] = (currencyValues[account.currency] || 0) + parseFloat(account.balance);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find the currency with the highest value
|
|
|
|
|
|
let majorityCurrency = 'EUR'; // default
|
|
|
|
|
|
let maxValue = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [currency, value] of Object.entries(currencyValues)) {
|
|
|
|
|
|
if (value > maxValue) {
|
|
|
|
|
|
maxValue = value;
|
|
|
|
|
|
majorityCurrency = currency;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return majorityCurrency;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-29 19:54:29 +01:00
|
|
|
|
escapeHtml(text) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.textContent = text;
|
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
|
}
|
2025-09-18 10:29:34 +01:00
|
|
|
|
|
|
|
|
|
|
// Subscription Management Functions
|
|
|
|
|
|
async renderSubscriptionsPage() {
|
|
|
|
|
|
await this.loadSubscriptions();
|
|
|
|
|
|
await this.loadSubscriptionsSummary();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async renderAddSubscriptionPage() {
|
|
|
|
|
|
// Set default date to today
|
|
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
|
|
document.getElementById('subscription-start-date').value = today;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadSubscriptions() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to fetch subscriptions');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const subscriptions = await response.json();
|
|
|
|
|
|
this.renderSubscriptionsList(subscriptions);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading subscriptions:', error);
|
|
|
|
|
|
this.showNotification('Failed to load subscriptions', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async loadSubscriptionsSummary() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Failed to fetch subscriptions summary');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const summary = await response.json();
|
|
|
|
|
|
this.renderSubscriptionsSummary(summary);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading subscriptions summary:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderSubscriptionsList(subscriptions) {
|
|
|
|
|
|
const container = document.getElementById('subscriptions-list');
|
|
|
|
|
|
|
|
|
|
|
|
if (subscriptions.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<p class="no-data">No subscriptions recorded yet. Add your first subscription above!</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const subscriptionsHTML = subscriptions.map(subscription => this.createSubscriptionHTML(subscription)).join('');
|
|
|
|
|
|
container.innerHTML = subscriptionsHTML;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
createSubscriptionHTML(subscription) {
|
|
|
|
|
|
const monthlyPrice = subscription.billingCycle === 'monthly'
|
|
|
|
|
|
? subscription.monthlyPrice
|
|
|
|
|
|
: (subscription.annualPrice / 12);
|
|
|
|
|
|
|
|
|
|
|
|
const currency = this.getCurrencySymbol(subscription.currency);
|
|
|
|
|
|
const nextBilling = this.calculateNextBilling(subscription.startDate, subscription.billingCycle);
|
|
|
|
|
|
const startDate = new Date(subscription.startDate).toLocaleDateString();
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="subscription-item ${subscription.billingCycle}" data-id="${subscription.id}">
|
|
|
|
|
|
<div class="subscription-header">
|
|
|
|
|
|
<span class="service-name">${this.escapeHtml(subscription.serviceName)}</span>
|
|
|
|
|
|
<span class="billing-cycle-badge ${subscription.billingCycle}">${subscription.billingCycle.toUpperCase()}</span>
|
|
|
|
|
|
<div class="subscription-header-right">
|
|
|
|
|
|
<span class="price">${currency}${monthlyPrice.toFixed(2)}/month</span>
|
|
|
|
|
|
${subscription.website_url ? `<a href="${subscription.website_url}" target="_blank" rel="noopener noreferrer" class="website-link" title="Visit ${subscription.serviceName}">🔗</a>` : ''}
|
|
|
|
|
|
<div class="subscription-actions">
|
|
|
|
|
|
<button class="edit-btn" onclick="app.openEditSubscriptionModal('${subscription.id}')">Edit</button>
|
|
|
|
|
|
<button class="delete-btn" onclick="app.deleteSubscription('${subscription.id}')">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="subscription-details">
|
|
|
|
|
|
<div class="subscription-info">
|
|
|
|
|
|
${subscription.billingCycle === 'annual' ? `<span class="annual-price">(${currency}${subscription.annualPrice.toFixed(2)}/year)</span>` : ''}
|
|
|
|
|
|
<span class="category">${subscription.category}</span>
|
|
|
|
|
|
${subscription.freeTrialDays > 0 ? `<span class="free-trial">Free trial: ${subscription.freeTrialDays} days</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="subscription-dates">
|
|
|
|
|
|
<span class="start-date">Started: ${startDate}</span>
|
|
|
|
|
|
<span class="next-billing">Next billing: ${nextBilling}</span>
|
|
|
|
|
|
${subscription.endDate ? `<span class="end-date">Ends: ${new Date(subscription.endDate).toLocaleDateString()}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${subscription.notes ? `<div class="subscription-notes">${this.escapeHtml(subscription.notes)}</div>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderSubscriptionsSummary(summary) {
|
|
|
|
|
|
const monthlyTotal = document.getElementById('monthly-total');
|
|
|
|
|
|
const annualTotal = document.getElementById('annual-total');
|
|
|
|
|
|
const totalSubscriptions = document.getElementById('total-subscriptions');
|
|
|
|
|
|
|
|
|
|
|
|
if (summary.length > 0) {
|
|
|
|
|
|
let totalMonthly = 0;
|
|
|
|
|
|
let totalAnnual = 0;
|
|
|
|
|
|
let totalSubs = 0;
|
|
|
|
|
|
|
|
|
|
|
|
summary.forEach(s => {
|
|
|
|
|
|
totalMonthly += s.total_monthly_cost || 0;
|
|
|
|
|
|
totalAnnual += s.total_annual_cost || 0;
|
|
|
|
|
|
totalSubs += s.total_subscriptions || 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
monthlyTotal.textContent = `€${totalMonthly.toFixed(2)}`;
|
|
|
|
|
|
annualTotal.textContent = `€${totalAnnual.toFixed(2)}`;
|
|
|
|
|
|
totalSubscriptions.textContent = totalSubs;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
monthlyTotal.textContent = '€0.00';
|
|
|
|
|
|
annualTotal.textContent = '€0.00';
|
|
|
|
|
|
totalSubscriptions.textContent = '0';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleSubscriptionSubmit(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData(e.target);
|
|
|
|
|
|
const subscriptionData = {
|
|
|
|
|
|
serviceName: formData.get('serviceName'),
|
|
|
|
|
|
category: formData.get('category'),
|
|
|
|
|
|
billingCycle: formData.get('billingCycle'),
|
|
|
|
|
|
currency: formData.get('currency'),
|
|
|
|
|
|
monthlyPrice: formData.get('monthlyPrice') ? parseFloat(formData.get('monthlyPrice')) : null,
|
|
|
|
|
|
annualPrice: formData.get('annualPrice') ? parseFloat(formData.get('annualPrice')) : null,
|
|
|
|
|
|
startDate: formData.get('startDate'),
|
|
|
|
|
|
endDate: formData.get('endDate') || null,
|
|
|
|
|
|
freeTrialDays: parseInt(formData.get('freeTrialDays')) || 0,
|
|
|
|
|
|
notes: formData.get('notes'),
|
|
|
|
|
|
websiteUrl: formData.get('websiteUrl')
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!subscriptionData.monthlyPrice && !subscriptionData.annualPrice) {
|
|
|
|
|
|
this.showNotification('Please provide either monthly or annual price', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(subscriptionData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to add subscription');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.showNotification('Subscription added successfully!', 'success');
|
|
|
|
|
|
e.target.reset();
|
|
|
|
|
|
this.showPage('subscriptions');
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard data
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding subscription:', error);
|
|
|
|
|
|
this.showNotification(error.message, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async deleteSubscription(subscriptionId) {
|
|
|
|
|
|
if (confirm('Are you sure you want to delete this subscription?')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions/${subscriptionId}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to delete subscription');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await this.loadSubscriptions();
|
|
|
|
|
|
await this.loadSubscriptionsSummary();
|
|
|
|
|
|
this.updateDashboard(); // Refresh dashboard data
|
|
|
|
|
|
this.showNotification('Subscription deleted successfully!', 'success');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error deleting subscription:', error);
|
|
|
|
|
|
this.showNotification(error.message || 'Failed to delete subscription', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Subscription Modal Functions
|
|
|
|
|
|
openEditSubscriptionModal(subscriptionId) {
|
|
|
|
|
|
// First fetch the subscription data
|
|
|
|
|
|
this.fetchSubscriptions().then(subscriptions => {
|
|
|
|
|
|
const subscription = subscriptions.find(s => s.id == subscriptionId);
|
|
|
|
|
|
if (!subscription) {
|
|
|
|
|
|
this.showNotification('Subscription not found', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Populate the form
|
|
|
|
|
|
document.getElementById('edit-subscription-service-name').value = subscription.serviceName;
|
|
|
|
|
|
document.getElementById('edit-subscription-category').value = subscription.category;
|
|
|
|
|
|
document.getElementById('edit-subscription-billing-cycle').value = subscription.billingCycle;
|
|
|
|
|
|
document.getElementById('edit-subscription-currency').value = subscription.currency;
|
|
|
|
|
|
document.getElementById('edit-subscription-monthly-price').value = subscription.monthlyPrice || '';
|
|
|
|
|
|
document.getElementById('edit-subscription-annual-price').value = subscription.annualPrice || '';
|
|
|
|
|
|
document.getElementById('edit-subscription-start-date').value = subscription.startDate;
|
|
|
|
|
|
document.getElementById('edit-subscription-end-date').value = subscription.endDate || '';
|
|
|
|
|
|
document.getElementById('edit-subscription-free-trial').value = subscription.freeTrialDays;
|
|
|
|
|
|
document.getElementById('edit-subscription-notes').value = subscription.notes || '';
|
|
|
|
|
|
document.getElementById('edit-subscription-website-url').value = subscription.website_url || '';
|
|
|
|
|
|
|
|
|
|
|
|
// Store the subscription ID for later use
|
|
|
|
|
|
const form = document.getElementById('edit-subscription-form');
|
|
|
|
|
|
form.dataset.subscriptionId = subscriptionId;
|
|
|
|
|
|
|
|
|
|
|
|
// Show the modal
|
|
|
|
|
|
document.getElementById('edit-subscription-modal').style.display = 'block';
|
|
|
|
|
|
}).catch(error => {
|
|
|
|
|
|
console.error('Error fetching subscription for edit:', error);
|
|
|
|
|
|
this.showNotification('Failed to load subscription data', 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
closeEditSubscriptionModal() {
|
|
|
|
|
|
document.getElementById('edit-subscription-modal').style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async handleEditSubscriptionSubmit(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const subscriptionId = e.target.dataset.subscriptionId;
|
|
|
|
|
|
const formData = new FormData(e.target);
|
|
|
|
|
|
const subscriptionData = {
|
|
|
|
|
|
serviceName: formData.get('serviceName'),
|
|
|
|
|
|
category: formData.get('category'),
|
|
|
|
|
|
billingCycle: formData.get('billingCycle'),
|
|
|
|
|
|
currency: formData.get('currency'),
|
|
|
|
|
|
monthlyPrice: formData.get('monthlyPrice') ? parseFloat(formData.get('monthlyPrice')) : null,
|
|
|
|
|
|
annualPrice: formData.get('annualPrice') ? parseFloat(formData.get('annualPrice')) : null,
|
|
|
|
|
|
startDate: formData.get('startDate'),
|
|
|
|
|
|
endDate: formData.get('endDate') || null,
|
|
|
|
|
|
freeTrialDays: parseInt(formData.get('freeTrialDays')) || 0,
|
|
|
|
|
|
notes: formData.get('notes'),
|
|
|
|
|
|
websiteUrl: formData.get('websiteUrl')
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!subscriptionData.monthlyPrice && !subscriptionData.annualPrice) {
|
|
|
|
|
|
this.showNotification('Please provide either monthly or annual price', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions/${subscriptionId}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
body: JSON.stringify(subscriptionData)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
|
throw new Error(error.error || 'Failed to update subscription');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.closeEditSubscriptionModal();
|
|
|
|
|
|
await this.loadSubscriptions();
|
|
|
|
|
|
await this.loadSubscriptionsSummary();
|
|
|
|
|
|
this.updateDashboard();
|
|
|
|
|
|
this.showNotification('Subscription updated successfully!', 'success');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating subscription:', error);
|
|
|
|
|
|
this.showNotification(error.message, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
exportSubscriptions() {
|
|
|
|
|
|
// First fetch all subscriptions
|
|
|
|
|
|
this.fetchSubscriptions().then(subscriptions => {
|
|
|
|
|
|
if (subscriptions.length === 0) {
|
|
|
|
|
|
this.showNotification('No subscriptions to export', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dataStr = JSON.stringify(subscriptions, null, 2);
|
|
|
|
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = URL.createObjectURL(dataBlob);
|
|
|
|
|
|
link.download = `subscriptions_${new Date().toISOString().split('T')[0]}.json`;
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
|
|
|
|
|
|
|
this.showNotification('Subscriptions exported successfully!', 'success');
|
|
|
|
|
|
}).catch(error => {
|
|
|
|
|
|
console.error('Error exporting subscriptions:', error);
|
|
|
|
|
|
this.showNotification('Failed to export subscriptions', 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
calculateNextBilling(startDate, billingCycle) {
|
|
|
|
|
|
const start = new Date(startDate);
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
|
|
|
|
|
|
let nextBilling = new Date(start);
|
|
|
|
|
|
|
|
|
|
|
|
while (nextBilling <= today) {
|
|
|
|
|
|
if (billingCycle === 'monthly') {
|
|
|
|
|
|
nextBilling.setMonth(nextBilling.getMonth() + 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextBilling.setFullYear(nextBilling.getFullYear() + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nextBilling.toLocaleDateString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Dashboard Subscription Functions
|
|
|
|
|
|
async loadDashboardSubscriptionData() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [subscriptions, summary] = await Promise.all([
|
|
|
|
|
|
this.fetchSubscriptions(),
|
|
|
|
|
|
this.fetchSubscriptionsSummary()
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
this.updateDashboardSubscriptionSummary(summary);
|
|
|
|
|
|
this.renderDashboardSubscriptions(subscriptions);
|
|
|
|
|
|
|
|
|
|
|
|
// Update the holdings section
|
|
|
|
|
|
this.updateHoldingsSubscriptions(summary);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading dashboard subscription data:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fetchSubscriptions() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
return response.ok ? await response.json() : [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching subscriptions:', error);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fetchSubscriptionsSummary() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
|
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
|
});
|
|
|
|
|
|
return response.ok ? await response.json() : [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching subscriptions summary:', error);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDashboardSubscriptionSummary(summary) {
|
|
|
|
|
|
const monthlyTotal = document.getElementById('dashboard-subscription-monthly');
|
|
|
|
|
|
const annualTotal = document.getElementById('dashboard-subscription-annual');
|
|
|
|
|
|
const subscriptionCount = document.getElementById('dashboard-subscription-count');
|
|
|
|
|
|
const nextBilling = document.getElementById('dashboard-next-billing');
|
|
|
|
|
|
|
|
|
|
|
|
if (summary.length > 0) {
|
|
|
|
|
|
let totalMonthly = 0;
|
|
|
|
|
|
let totalAnnual = 0;
|
|
|
|
|
|
let totalSubs = 0;
|
|
|
|
|
|
|
|
|
|
|
|
summary.forEach(s => {
|
|
|
|
|
|
totalMonthly += s.total_monthly_cost || 0;
|
|
|
|
|
|
totalAnnual += s.total_annual_cost || 0;
|
|
|
|
|
|
totalSubs += s.total_subscriptions || 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (monthlyTotal) monthlyTotal.textContent = `€${totalMonthly.toFixed(2)}`;
|
|
|
|
|
|
if (annualTotal) annualTotal.textContent = `€${totalAnnual.toFixed(2)}`;
|
|
|
|
|
|
if (subscriptionCount) subscriptionCount.textContent = `${totalSubs} subscription${totalSubs !== 1 ? 's' : ''}`;
|
|
|
|
|
|
|
|
|
|
|
|
// Show subscription breakdown if there are subscriptions
|
|
|
|
|
|
const subscriptionBreakdown = document.getElementById('subscription-breakdown');
|
|
|
|
|
|
if (subscriptionBreakdown && totalSubs > 0) {
|
|
|
|
|
|
subscriptionBreakdown.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (monthlyTotal) monthlyTotal.textContent = '€0.00';
|
|
|
|
|
|
if (annualTotal) annualTotal.textContent = '€0.00';
|
|
|
|
|
|
if (subscriptionCount) subscriptionCount.textContent = '0 subscriptions';
|
|
|
|
|
|
if (nextBilling) nextBilling.textContent = 'Next billing: --';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderDashboardSubscriptions(subscriptions) {
|
|
|
|
|
|
const container = document.getElementById('dashboard-subscriptions-list');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (subscriptions.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<p class="no-data">No subscriptions yet</p>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Show top 5 subscriptions by monthly cost
|
|
|
|
|
|
const sortedSubscriptions = subscriptions
|
|
|
|
|
|
.map(sub => ({
|
|
|
|
|
|
...sub,
|
|
|
|
|
|
monthlyEquivalent: sub.billingCycle === 'monthly'
|
|
|
|
|
|
? sub.monthlyPrice
|
|
|
|
|
|
: (sub.annualPrice / 12)
|
|
|
|
|
|
}))
|
|
|
|
|
|
.sort((a, b) => b.monthlyEquivalent - a.monthlyEquivalent)
|
|
|
|
|
|
.slice(0, 5);
|
|
|
|
|
|
|
|
|
|
|
|
let nextBillingDate = null;
|
|
|
|
|
|
let nextBillingService = '';
|
|
|
|
|
|
|
|
|
|
|
|
const subscriptionItems = sortedSubscriptions.map(subscription => {
|
|
|
|
|
|
const currency = this.getCurrencySymbol(subscription.currency);
|
|
|
|
|
|
const nextBilling = this.calculateNextBilling(subscription.startDate, subscription.billingCycle);
|
|
|
|
|
|
|
|
|
|
|
|
// Track the nearest billing date
|
|
|
|
|
|
const billingDateObj = new Date(nextBilling.split('/').reverse().join('-'));
|
|
|
|
|
|
if (!nextBillingDate || billingDateObj < nextBillingDate) {
|
|
|
|
|
|
nextBillingDate = billingDateObj;
|
|
|
|
|
|
nextBillingService = subscription.serviceName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="subscription-breakdown-item" style="border-left: 3px solid #6f42c1; cursor: pointer;" onclick="app.showPage('subscriptions')">
|
|
|
|
|
|
<div class="subscription-name">${this.escapeHtml(subscription.serviceName)}</div>
|
|
|
|
|
|
<div class="subscription-details">
|
|
|
|
|
|
<span class="subscription-price">${currency}${subscription.monthlyEquivalent.toFixed(2)}/month</span>
|
|
|
|
|
|
<span class="subscription-cycle">${subscription.billingCycle}</span>
|
|
|
|
|
|
<span class="subscription-category">${subscription.category}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = subscriptionItems;
|
|
|
|
|
|
|
|
|
|
|
|
// Update next billing information
|
|
|
|
|
|
const nextBillingElement = document.getElementById('dashboard-next-billing');
|
|
|
|
|
|
if (nextBillingElement && nextBillingDate) {
|
|
|
|
|
|
const billingText = `Next billing: ${nextBillingService} on ${nextBillingDate.toLocaleDateString()}`;
|
|
|
|
|
|
nextBillingElement.textContent = billingText;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateHoldingsSubscriptions(summary) {
|
|
|
|
|
|
const subscriptionsValue = document.getElementById('total-holdings-subscriptions');
|
|
|
|
|
|
if (!subscriptionsValue) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (summary.length > 0) {
|
|
|
|
|
|
let totalMonthly = 0;
|
|
|
|
|
|
summary.forEach(s => {
|
|
|
|
|
|
totalMonthly += s.total_monthly_cost || 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
subscriptionsValue.textContent = `€${totalMonthly.toFixed(2)}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
subscriptionsValue.textContent = '€0.00';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-23 13:58:43 +00:00
|
|
|
|
|
|
|
|
|
|
// Growth Charts Functionality
|
|
|
|
|
|
initializeGrowthCharts() {
|
|
|
|
|
|
// Initialize chart data storage
|
|
|
|
|
|
this.chartData = {
|
|
|
|
|
|
portfolio: [],
|
|
|
|
|
|
cash: [],
|
|
|
|
|
|
total: [],
|
|
|
|
|
|
currentPeriod: '7d'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bind period selector events
|
|
|
|
|
|
const periodButtons = document.querySelectorAll('.period-btn');
|
|
|
|
|
|
periodButtons.forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
|
|
|
|
// Update active button
|
|
|
|
|
|
periodButtons.forEach(b => b.classList.remove('active'));
|
|
|
|
|
|
e.target.classList.add('active');
|
|
|
|
|
|
|
|
|
|
|
|
// Update period and refresh charts
|
|
|
|
|
|
this.chartData.currentPeriod = e.target.dataset.period;
|
|
|
|
|
|
this.updateGrowthCharts();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Generate initial mock data
|
|
|
|
|
|
this.generateMockChartData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
generateMockChartData() {
|
|
|
|
|
|
// Generate mock historical data for different time periods
|
|
|
|
|
|
const periods = {
|
|
|
|
|
|
'7d': 7,
|
|
|
|
|
|
'1m': 30,
|
|
|
|
|
|
'3m': 90,
|
|
|
|
|
|
'6m': 180,
|
|
|
|
|
|
'1y': 365
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Object.keys(periods).forEach(period => {
|
|
|
|
|
|
const days = periods[period];
|
|
|
|
|
|
const portfolioData = [];
|
|
|
|
|
|
const cashData = [];
|
|
|
|
|
|
const totalData = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Get current values as base
|
|
|
|
|
|
const currentPortfolio = this.calculatePortfolioTotals().currentValue;
|
|
|
|
|
|
const currentCash = 5000; // Mock current cash value
|
|
|
|
|
|
const currentTotal = currentPortfolio + currentCash;
|
|
|
|
|
|
|
|
|
|
|
|
// Generate historical values with some growth trend
|
|
|
|
|
|
for (let i = days; i >= 0; i--) {
|
|
|
|
|
|
const date = new Date();
|
|
|
|
|
|
date.setDate(date.getDate() - i);
|
|
|
|
|
|
|
|
|
|
|
|
// Add some randomness but overall upward trend
|
|
|
|
|
|
const growthFactor = Math.pow(1.0008, days - i); // 0.08% daily growth on average
|
|
|
|
|
|
const randomFactor = 0.95 + Math.random() * 0.1; // ±5% random variation
|
|
|
|
|
|
|
|
|
|
|
|
const portfolioValue = currentPortfolio / growthFactor * randomFactor;
|
|
|
|
|
|
const cashValue = currentCash + (Math.random() - 0.5) * 1000; // Cash fluctuates more
|
|
|
|
|
|
|
|
|
|
|
|
portfolioData.push({ date, value: portfolioValue });
|
|
|
|
|
|
cashData.push({ date, value: Math.max(0, cashValue) });
|
|
|
|
|
|
totalData.push({ date, value: portfolioValue + Math.max(0, cashValue) });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.chartData[`portfolio_${period}`] = portfolioData;
|
|
|
|
|
|
this.chartData[`cash_${period}`] = cashData;
|
|
|
|
|
|
this.chartData[`total_${period}`] = totalData;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateGrowthCharts() {
|
|
|
|
|
|
const period = this.chartData.currentPeriod;
|
|
|
|
|
|
|
|
|
|
|
|
// Update portfolio chart
|
|
|
|
|
|
this.renderChart('portfolio-chart', this.chartData[`portfolio_${period}`], '#28a745');
|
|
|
|
|
|
this.updateChartStats('portfolio', this.chartData[`portfolio_${period}`]);
|
|
|
|
|
|
|
|
|
|
|
|
// Update cash chart
|
|
|
|
|
|
this.renderChart('cash-chart', this.chartData[`cash_${period}`], '#17a2b8');
|
|
|
|
|
|
this.updateChartStats('cash', this.chartData[`cash_${period}`]);
|
|
|
|
|
|
|
|
|
|
|
|
// Update total chart
|
|
|
|
|
|
this.renderChart('total-chart', this.chartData[`total_${period}`], '#667eea');
|
|
|
|
|
|
this.updateChartStats('total', this.chartData[`total_${period}`]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderChart(canvasId, data, color) {
|
|
|
|
|
|
const canvas = document.getElementById(canvasId);
|
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
|
|
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
|
|
// Set canvas size
|
|
|
|
|
|
canvas.width = rect.width * window.devicePixelRatio;
|
|
|
|
|
|
canvas.height = rect.height * window.devicePixelRatio;
|
|
|
|
|
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
|
|
|
|
|
|
|
|
|
|
|
// Clear canvas
|
|
|
|
|
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
|
|
|
|
|
|
|
|
|
|
if (!data || data.length < 2) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate bounds
|
|
|
|
|
|
const values = data.map(d => d.value);
|
|
|
|
|
|
const minValue = Math.min(...values);
|
|
|
|
|
|
const maxValue = Math.max(...values);
|
|
|
|
|
|
const valueRange = maxValue - minValue;
|
|
|
|
|
|
const padding = 20;
|
|
|
|
|
|
|
|
|
|
|
|
// Draw grid lines
|
|
|
|
|
|
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border-light');
|
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
|
ctx.setLineDash([2, 2]);
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i <= 4; i++) {
|
|
|
|
|
|
const y = padding + (i * (rect.height - 2 * padding)) / 4;
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.moveTo(padding, y);
|
|
|
|
|
|
ctx.lineTo(rect.width - padding, y);
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx.setLineDash([]);
|
|
|
|
|
|
|
|
|
|
|
|
// Draw line chart
|
|
|
|
|
|
ctx.strokeStyle = color;
|
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
|
|
|
|
data.forEach((point, index) => {
|
|
|
|
|
|
const x = padding + (index * (rect.width - 2 * padding)) / (data.length - 1);
|
|
|
|
|
|
const y = rect.height - padding - ((point.value - minValue) / valueRange) * (rect.height - 2 * padding);
|
|
|
|
|
|
|
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
|
ctx.moveTo(x, y);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ctx.lineTo(x, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
// Fill area under curve
|
|
|
|
|
|
ctx.globalAlpha = 0.1;
|
|
|
|
|
|
ctx.fillStyle = color;
|
|
|
|
|
|
ctx.lineTo(rect.width - padding, rect.height - padding);
|
|
|
|
|
|
ctx.lineTo(padding, rect.height - padding);
|
|
|
|
|
|
ctx.closePath();
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateChartStats(type, data) {
|
|
|
|
|
|
if (!data || data.length < 2) return;
|
|
|
|
|
|
|
|
|
|
|
|
const latest = data[data.length - 1];
|
|
|
|
|
|
const previous = data[0];
|
|
|
|
|
|
const change = ((latest.value - previous.value) / previous.value) * 100;
|
|
|
|
|
|
|
|
|
|
|
|
// Update value display
|
|
|
|
|
|
const valueElement = document.getElementById(`${type}-graph-value`);
|
|
|
|
|
|
const changeElement = document.getElementById(`${type}-graph-change`);
|
|
|
|
|
|
|
|
|
|
|
|
if (valueElement) {
|
|
|
|
|
|
valueElement.textContent = this.formatCurrency(latest.value, 'EUR');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (changeElement) {
|
|
|
|
|
|
const changeText = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
|
|
|
|
|
|
changeElement.textContent = changeText;
|
|
|
|
|
|
|
|
|
|
|
|
// Update change indicator classes
|
|
|
|
|
|
changeElement.className = 'change-indicator';
|
|
|
|
|
|
if (change > 0) {
|
|
|
|
|
|
changeElement.classList.add('positive');
|
|
|
|
|
|
} else if (change < 0) {
|
|
|
|
|
|
changeElement.classList.add('negative');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
changeElement.classList.add('neutral');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-28 16:36:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 13:58:43 +00:00
|
|
|
|
const app = new PersonalFinanceTracker();
|