Features added: - Dashboard now shows top 5 holdings, accounts, and subscriptions under each segment - Quick action cards positioned underneath data cards for easy access - Cancel/Add buttons added to all forms (trade, cash account, subscription) - Auto-focus functionality when navigating from quick actions - Responsive design optimized for mobile and desktop - Consistent styling following existing design patterns UI improvements: - Card groups structure for better organization - Top accounts sections with hover effects and proper formatting - Form actions container with proper button alignment - Mobile-responsive quick action cards and forms 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
3707 lines
150 KiB
JavaScript
3707 lines
150 KiB
JavaScript
class ETFTradeTracker {
|
||
constructor() {
|
||
this.trades = [];
|
||
this.currentPrices = new Map(); // Store current market prices
|
||
this.cgtSettings = null; // Store CGT settings
|
||
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();
|
||
await this.loadCGTSettings();
|
||
await this.loadLatestPrices();
|
||
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');
|
||
const subscriptionForm = document.getElementById('subscription-form');
|
||
|
||
form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||
clearBtn.addEventListener('click', () => this.clearAllTrades());
|
||
exportBtn.addEventListener('click', () => this.exportTrades());
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
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;
|
||
this.navigateToPage(page);
|
||
});
|
||
});
|
||
|
||
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');
|
||
}
|
||
});
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
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',
|
||
'cgt-settings': 'CGT Settings',
|
||
'tokens': 'API Tokens',
|
||
'cash-accounts': 'Cash Accounts',
|
||
'add-transfer': 'Add Transfer',
|
||
'subscriptions': 'Subscriptions',
|
||
'add-subscription': 'Add Subscription',
|
||
'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();
|
||
} else if (pageId === 'cgt-settings') {
|
||
this.renderCGTSettingsPage();
|
||
} else if (pageId === 'tokens') {
|
||
this.renderTokensPage();
|
||
} else if (pageId === 'cash-accounts') {
|
||
this.renderCashAccountsPage();
|
||
// 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);
|
||
}
|
||
} else if (pageId === 'add-transfer') {
|
||
this.renderAddTransferPage();
|
||
} else if (pageId === 'subscriptions') {
|
||
this.renderSubscriptionsPage();
|
||
} else if (pageId === 'add-subscription') {
|
||
this.renderAddSubscriptionPage();
|
||
// 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);
|
||
} 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];
|
||
const currencySymbol = this.getCurrencySymbol(trade.currency);
|
||
|
||
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() {
|
||
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)}%`;
|
||
|
||
// Load and display top accounts for each segment
|
||
this.loadDashboardTopAccounts();
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
|
||
// Update total holdings card with portfolio data
|
||
this.updateTotalHoldingsCard();
|
||
}
|
||
|
||
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 => {
|
||
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
||
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;
|
||
}
|
||
|
||
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);
|
||
const currencySymbol = this.getCurrencySymbol(account.currency);
|
||
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>
|
||
`;
|
||
}
|
||
}
|
||
|
||
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 => {
|
||
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
||
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) || '';
|
||
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
||
|
||
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>
|
||
<button class="view-history-btn" data-symbol="${symbol}">History</button>
|
||
</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');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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);
|
||
});
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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);
|
||
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
||
|
||
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';
|
||
}
|
||
|
||
// Calculate and display CGT information
|
||
this.calculateAndDisplayCGT(etfMap, hasCurrentPrices);
|
||
|
||
// Calculate and display 8+ year CGT information
|
||
this.calculateAndDisplayLongTermCGT(etfMap, hasCurrentPrices);
|
||
|
||
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;
|
||
const currencySymbol = this.getCurrencySymbol(etf.currency);
|
||
|
||
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;
|
||
}
|
||
|
||
formatCurrency(amount, currency = null) {
|
||
// Use majority currency if none specified
|
||
const currencyToUse = currency || this.getMajorityCurrency();
|
||
const symbol = this.getCurrencySymbol(currencyToUse);
|
||
|
||
const absAmount = Math.abs(amount);
|
||
if (absAmount >= 1000000) {
|
||
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000000).toFixed(2) + 'M';
|
||
} else if (absAmount >= 1000) {
|
||
return (amount >= 0 ? symbol : '-' + symbol) + (absAmount / 1000).toFixed(1) + 'K';
|
||
} else {
|
||
return (amount >= 0 ? symbol : '-' + symbol) + absAmount.toFixed(2);
|
||
}
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
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;
|
||
const cgtRate = (this.cgtSettings?.rate_8years || 33) / 100; // Rate from settings for 8+ years
|
||
|
||
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 => {
|
||
const currencySymbol = this.getCurrencySymbol(position.currency);
|
||
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;
|
||
}
|
||
|
||
// 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,
|
||
rate_8years: 33.0,
|
||
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,
|
||
rate_8years: 33.0,
|
||
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;
|
||
document.getElementById('cgt-8years').value = this.cgtSettings.rate_8years;
|
||
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),
|
||
rate_8years: parseFloat(document.getElementById('cgt-8years').value),
|
||
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;
|
||
document.getElementById('cgt-8years').value = 33;
|
||
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,
|
||
'longterm': parseFloat(document.getElementById('cgt-longterm').value) || 0,
|
||
'8years': parseFloat(document.getElementById('cgt-8years').value) || 0
|
||
};
|
||
|
||
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}%`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
const currencySymbol = this.getCurrencySymbol(account.currency);
|
||
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);
|
||
const currencySymbol = this.getCurrencySymbol(transfer.currency);
|
||
|
||
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 => {
|
||
const currencySymbol = this.getCurrencySymbol(account.currency);
|
||
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 => {
|
||
const currencySymbol = this.getCurrencySymbol(account.currency);
|
||
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);
|
||
const currencySymbol = this.getCurrencySymbol(transfer.currency);
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
const currencySymbol = this.getCurrencySymbol(history[0]?.currency);
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
getCurrencySymbol(currency) {
|
||
switch(currency) {
|
||
case 'EUR': return '€';
|
||
case 'USD': return '$';
|
||
case 'GBP': return '£';
|
||
default: return currency;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
}
|
||
}
|
||
|
||
const app = new ETFTradeTracker(); |