Add comprehensive cash savings and transfers system with total holdings tracking

- Add cash accounts management with CRUD operations and account types
- Implement date-stamped transfer system with deposits/withdrawals
- Add total holdings card combining portfolio and cash values
- Create dedicated "Add Transfer" page in navigation
- Include automatic balance calculations and transaction history
- Add database migrations for cash_accounts and cash_transfers tables
- Integrate cash data with dashboard and real-time updates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kris 2025-08-29 18:54:29 +00:00
parent 503addf705
commit f10332d9f5
4 changed files with 3054 additions and 22 deletions

View File

@ -21,22 +21,34 @@
<span class="menu-icon">💼</span> <span class="menu-icon">💼</span>
<span class="menu-text">Portfolio</span> <span class="menu-text">Portfolio</span>
</li> </li>
<li class="menu-item" data-page="trade-history"> <li class="menu-item" data-page="add-trade">
<span class="menu-icon">📋</span> <span class="menu-icon"></span>
<span class="menu-text">Trade History</span> <span class="menu-text">Add Trade</span>
</li>
<li class="menu-item" data-page="cash-accounts">
<span class="menu-icon">💰</span>
<span class="menu-text">Cash Accounts</span>
</li>
<li class="menu-item" data-page="add-transfer">
<span class="menu-icon">💸</span>
<span class="menu-text">Add Transfer</span>
</li> </li>
<li class="menu-item" data-page="gains-losses"> <li class="menu-item" data-page="gains-losses">
<span class="menu-icon">📈</span> <span class="menu-icon">📈</span>
<span class="menu-text">Gains/Losses</span> <span class="menu-text">Gains/Losses</span>
</li> </li>
<li class="menu-item menu-separator" data-page="add-trade"> <li class="menu-item" data-page="trade-history">
<span class="menu-icon"></span> <span class="menu-icon">📋</span>
<span class="menu-text">Add Trade</span> <span class="menu-text">Trade History</span>
</li> </li>
<li class="menu-item" data-page="cgt-settings"> <li class="menu-item menu-separator" data-page="cgt-settings">
<span class="menu-icon">🧮</span> <span class="menu-icon">🧮</span>
<span class="menu-text">CGT Settings</span> <span class="menu-text">CGT Settings</span>
</li> </li>
<li class="menu-item" data-page="tokens">
<span class="menu-icon">🔑</span>
<span class="menu-text">API Tokens</span>
</li>
<li class="menu-item admin-only" data-page="admin" style="display: none;"> <li class="menu-item admin-only" data-page="admin" style="display: none;">
<span class="menu-icon">👥</span> <span class="menu-icon">👥</span>
<span class="menu-text">Admin Panel</span> <span class="menu-text">Admin Panel</span>
@ -120,6 +132,53 @@
</div> </div>
</div> </div>
<!-- Total Holdings Card -->
<div class="total-holdings-section">
<div class="total-holdings-card">
<h3>Total Holdings</h3>
<div class="holdings-breakdown">
<div class="holdings-item">
<span class="holdings-label">Portfolio Value:</span>
<span class="holdings-value" id="total-holdings-portfolio">€0.00</span>
</div>
<div class="holdings-item">
<span class="holdings-label">Cash Savings:</span>
<span class="holdings-value" id="total-holdings-cash">€0.00</span>
</div>
<div class="holdings-divider"></div>
<div class="holdings-total">
<span class="holdings-label">Total Value:</span>
<span class="holdings-value total" id="total-holdings-combined">€0.00</span>
</div>
</div>
</div>
</div>
<div class="cash-breakdown" id="cash-breakdown" style="display: none;">
<h3>Cash Savings Summary</h3>
<div class="cash-summary-overview">
<div class="cash-total-card">
<div class="cash-amount-line" id="dashboard-cash-eur-line">
<span class="currency-label">EUR:</span>
<span class="amount" id="dashboard-cash-eur">€0.00</span>
</div>
<div class="cash-amount-line" id="dashboard-cash-usd-line" style="display: none;">
<span class="currency-label">USD:</span>
<span class="amount" id="dashboard-cash-usd">$0.00</span>
</div>
</div>
<div class="cash-stats-card">
<span class="stat-detail" id="dashboard-account-count">0 accounts</span>
<span class="stat-detail" id="dashboard-avg-interest-display" style="display: none;">Avg: 0.0%</span>
</div>
</div>
<div id="cash-accounts-breakdown" class="breakdown-list">
<div id="dashboard-cash-accounts-list">
<p class="no-data">No cash accounts yet</p>
</div>
</div>
</div>
<div class="etf-breakdown"> <div class="etf-breakdown">
<h3>ETF Breakdown</h3> <h3>ETF Breakdown</h3>
<div id="etf-breakdown-list" class="breakdown-list"> <div id="etf-breakdown-list" class="breakdown-list">
@ -257,6 +316,337 @@
</div> </div>
</div> </div>
<!-- API Tokens Page -->
<div id="tokens-page" class="page">
<div class="tokens-container">
<h2>API Access Tokens</h2>
<div class="tokens-intro">
<p>Create access tokens to authenticate API requests for third-party integrations like n8n workflows. Keep your tokens secure and never share them publicly.</p>
</div>
<div class="token-actions">
<h3>Create New Token</h3>
<form id="create-token-form" class="create-token-form">
<div class="form-row">
<div class="form-group">
<label for="token-name">Token Name</label>
<input type="text" id="token-name" required placeholder="e.g., N8N Workflow Token" maxlength="50">
</div>
<div class="form-group">
<label for="token-expires">Expires (optional)</label>
<select id="token-expires">
<option value="">Never</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="token-scopes">Permissions</label>
<select id="token-scopes">
<option value="read">Read Only</option>
<option value="read,write" selected>Read & Write</option>
</select>
</div>
</div>
<button type="submit" class="create-token-btn">Create Token</button>
</form>
</div>
<div class="tokens-list-section">
<h3>Your Access Tokens</h3>
<div id="tokens-list" class="tokens-list">
<p class="no-data">Loading tokens...</p>
</div>
</div>
<!-- Token Creation Modal -->
<div id="token-modal" class="modal" style="display: none;">
<div class="modal-content">
<h3>Token Created Successfully</h3>
<p><strong>Important:</strong> Copy your token now. You won't be able to see it again!</p>
<div class="token-display">
<input type="text" id="new-token-value" readonly>
<button id="copy-token-btn" class="copy-btn">Copy</button>
</div>
<div class="token-usage">
<h4>Usage Example:</h4>
<code class="usage-example">
curl -H "Authorization: Bearer YOUR_TOKEN" \<br>
&nbsp;&nbsp;&nbsp;&nbsp;http://localhost:3000/api/trades
</code>
</div>
<button id="close-token-modal" class="close-modal-btn">Close</button>
</div>
</div>
</div>
</div>
<!-- Cash Accounts Page -->
<div id="cash-accounts-page" class="page">
<div class="cash-accounts-container">
<h2>Cash Savings Accounts</h2>
<div class="cash-intro">
<p>Track your cash savings accounts alongside your ETF investments. Manage different account types and monitor your total cash position.</p>
</div>
<div class="cash-summary-cards">
<div class="cash-summary-card total-cash">
<h3>Total Cash</h3>
<div class="cash-amounts">
<div class="amount-line">
<span class="currency">EUR:</span>
<span class="amount" id="total-cash-eur">€0.00</span>
</div>
<div class="amount-line">
<span class="currency">USD:</span>
<span class="amount" id="total-cash-usd">$0.00</span>
</div>
</div>
</div>
<div class="cash-summary-card account-stats">
<h3>Account Stats</h3>
<div class="stat-line">
<span>Accounts:</span>
<span id="account-count">0</span>
</div>
<div class="stat-line">
<span>Avg Interest:</span>
<span id="avg-interest">0.0%</span>
</div>
</div>
</div>
<div class="cash-actions">
<h3>Add New Account</h3>
<form id="create-account-form" class="create-account-form">
<div class="form-row">
<div class="form-group">
<label for="account-name">Account Name</label>
<input type="text" id="account-name" required placeholder="e.g., Emergency Savings" maxlength="100">
</div>
<div class="form-group">
<label for="account-type">Account Type</label>
<select id="account-type">
<option value="savings">Savings Account</option>
<option value="checking">Checking Account</option>
<option value="money_market">Money Market</option>
<option value="cd">Certificate of Deposit</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="account-balance">Balance</label>
<input type="number" id="account-balance" step="0.01" min="0" required placeholder="0.00">
</div>
<div class="form-group">
<label for="account-currency">Currency</label>
<select id="account-currency">
<option value="EUR" selected>EUR (€)</option>
<option value="USD">USD ($)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="institution-name">Institution (optional)</label>
<input type="text" id="institution-name" placeholder="e.g., Bank of Ireland">
</div>
<div class="form-group">
<label for="interest-rate">Interest Rate % (optional)</label>
<input type="number" id="interest-rate" step="0.01" min="0" placeholder="0.00">
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label for="account-notes">Notes (optional)</label>
<textarea id="account-notes" placeholder="Additional notes about this account" rows="2"></textarea>
</div>
</div>
<button type="submit" class="create-account-btn">Add Account</button>
</form>
</div>
<div class="accounts-list-section">
<h3>Your Cash Accounts</h3>
<div id="accounts-list" class="accounts-list">
<p class="no-data">Loading accounts...</p>
</div>
</div>
<!-- Edit Account Modal -->
<div id="edit-account-modal" class="modal" style="display: none;">
<div class="modal-content">
<h3>Edit Cash Account</h3>
<form id="edit-account-form">
<div class="form-row">
<div class="form-group">
<label for="edit-account-name">Account Name</label>
<input type="text" id="edit-account-name" required maxlength="100">
</div>
<div class="form-group">
<label for="edit-account-type">Account Type</label>
<select id="edit-account-type">
<option value="savings">Savings Account</option>
<option value="checking">Checking Account</option>
<option value="money_market">Money Market</option>
<option value="cd">Certificate of Deposit</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-account-balance">Balance</label>
<input type="number" id="edit-account-balance" step="0.01" min="0" required>
</div>
<div class="form-group">
<label for="edit-account-currency">Currency</label>
<select id="edit-account-currency">
<option value="EUR">EUR (€)</option>
<option value="USD">USD ($)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-institution-name">Institution</label>
<input type="text" id="edit-institution-name">
</div>
<div class="form-group">
<label for="edit-interest-rate">Interest Rate %</label>
<input type="number" id="edit-interest-rate" step="0.01" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label for="edit-account-notes">Notes</label>
<textarea id="edit-account-notes" rows="2"></textarea>
</div>
</div>
<div class="modal-buttons">
<button type="button" id="cancel-edit-account" class="cancel-btn">Cancel</button>
<button type="submit" class="save-btn">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Transfer Modal -->
<div id="transfer-modal" class="modal" style="display: none;">
<div class="modal-content">
<h3>Add Transfer</h3>
<form id="transfer-form">
<div class="form-row">
<div class="form-group">
<label for="transfer-account">Account</label>
<select id="transfer-account" required>
<option value="">Select Account</option>
</select>
</div>
<div class="form-group">
<label for="transfer-type">Transfer Type</label>
<select id="transfer-type" required>
<option value="">Select Type</option>
<option value="deposit">Deposit</option>
<option value="withdrawal">Withdrawal</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="transfer-amount">Amount</label>
<input type="number" id="transfer-amount" step="0.01" min="0.01" required>
</div>
<div class="form-group">
<label for="transfer-date">Transfer Date</label>
<input type="date" id="transfer-date" required>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label for="transfer-description">Description</label>
<textarea id="transfer-description" rows="2" placeholder="Optional description"></textarea>
</div>
</div>
<div class="modal-buttons">
<button type="button" id="cancel-transfer" class="cancel-btn">Cancel</button>
<button type="submit" class="save-btn">Add Transfer</button>
</div>
</form>
</div>
</div>
<!-- Transfers Section -->
<div class="transfers-section">
<div class="section-header">
<h3>Recent Transfers</h3>
<button id="add-transfer-btn" class="primary-btn">Add Transfer</button>
</div>
<div id="transfers-list" class="transfers-list">
<p class="no-data">No transfers found</p>
</div>
</div>
</div>
</div>
<!-- Add Transfer Page -->
<div id="add-transfer-page" class="page">
<div class="transfer-form-container">
<h2>Add Cash Transfer</h2>
<div class="transfer-intro">
<p>Record deposits and withdrawals for your cash accounts. Transfers will automatically update account balances.</p>
</div>
<form id="standalone-transfer-form" class="transfer-form">
<div class="form-row">
<div class="form-group">
<label for="standalone-transfer-account">Account</label>
<select id="standalone-transfer-account" required>
<option value="">Select Account</option>
</select>
</div>
<div class="form-group">
<label for="standalone-transfer-type">Transfer Type</label>
<select id="standalone-transfer-type" required>
<option value="">Select Type</option>
<option value="deposit">Deposit</option>
<option value="withdrawal">Withdrawal</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="standalone-transfer-amount">Amount</label>
<input type="number" id="standalone-transfer-amount" step="0.01" min="0.01" required>
</div>
<div class="form-group">
<label for="standalone-transfer-date">Transfer Date</label>
<input type="date" id="standalone-transfer-date" required>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label for="standalone-transfer-description">Description</label>
<textarea id="standalone-transfer-description" rows="3" placeholder="Optional description"></textarea>
</div>
</div>
<button type="submit" class="submit-btn">Add Transfer</button>
</form>
<div class="recent-transfers">
<h3>Recent Transfers</h3>
<div id="standalone-transfers-list" class="transfers-list">
<p class="no-data">No transfers found</p>
</div>
</div>
</div>
</div>
<!-- Gains/Losses Page --> <!-- Gains/Losses Page -->
<div id="gains-losses-page" class="page"> <div id="gains-losses-page" class="page">
<div class="gains-losses-container"> <div class="gains-losses-container">
@ -407,9 +797,14 @@
</div> </div>
<div class="cgt-rate-item"> <div class="cgt-rate-item">
<label for="cgt-longterm">2+ Years (%)</label> <label for="cgt-longterm">2-8 Years (%)</label>
<input type="number" id="cgt-longterm" min="0" max="100" step="0.1" value="10" class="cgt-rate-input"> <input type="number" id="cgt-longterm" min="0" max="100" step="0.1" value="10" class="cgt-rate-input">
</div> </div>
<div class="cgt-rate-item">
<label for="cgt-8years">8+ Years (%)</label>
<input type="number" id="cgt-8years" min="0" max="100" step="0.1" value="33" class="cgt-rate-input">
</div>
</div> </div>
<div class="cgt-options"> <div class="cgt-options">
@ -454,9 +849,13 @@
<div class="rate-value">20%</div> <div class="rate-value">20%</div>
</div> </div>
<div class="rate-bar" data-period="longterm"> <div class="rate-bar" data-period="longterm">
<span class="period-label">2Y+</span> <span class="period-label">2-8Y</span>
<div class="rate-value">10%</div> <div class="rate-value">10%</div>
</div> </div>
<div class="rate-bar" data-period="8years">
<span class="period-label">8Y+</span>
<div class="rate-value">33%</div>
</div>
</div> </div>
</div> </div>
</div> </div>

1038
script.js

File diff suppressed because it is too large Load Diff

642
server.js
View File

@ -4,6 +4,7 @@ const cors = require('cors');
const path = require('path'); const path = require('path');
const session = require('express-session'); const session = require('express-session');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const app = express(); const app = express();
@ -88,6 +89,35 @@ function initializeDatabase() {
console.error('Error creating trades table:', err.message); console.error('Error creating trades table:', err.message);
} else { } else {
console.log('Trades table ready'); console.log('Trades table ready');
// Check if user_id column exists, if not add it (for existing databases)
db.all("PRAGMA table_info(trades)", (err, columns) => {
if (err) {
console.error('Error checking trades table structure:', err.message);
return;
}
const hasUserId = columns.some(col => col.name === 'user_id');
if (!hasUserId) {
console.log('Adding user_id column to existing trades table...');
db.run("ALTER TABLE trades ADD COLUMN user_id INTEGER DEFAULT 1", (err) => {
if (err) {
console.error('Error adding user_id column:', err.message);
} else {
console.log('user_id column added to trades table');
// Update all existing trades to belong to user 1 (admin)
db.run("UPDATE trades SET user_id = 1 WHERE user_id IS NULL", (err) => {
if (err) {
console.error('Error updating existing trades:', err.message);
} else {
console.log('Existing trades updated with user_id');
}
});
}
});
}
});
} }
}); });
@ -101,6 +131,7 @@ function initializeDatabase() {
rate_1year REAL DEFAULT 30.0, rate_1year REAL DEFAULT 30.0,
rate_2years REAL DEFAULT 20.0, rate_2years REAL DEFAULT 20.0,
rate_longterm REAL DEFAULT 10.0, rate_longterm REAL DEFAULT 10.0,
rate_8years REAL DEFAULT 33.0,
annual_exemption REAL DEFAULT 1270.0, annual_exemption REAL DEFAULT 1270.0,
enabled BOOLEAN DEFAULT 1, enabled BOOLEAN DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@ -116,6 +147,81 @@ function initializeDatabase() {
console.log('CGT settings table ready'); console.log('CGT settings table ready');
} }
}); });
// Create access tokens table
const createAccessTokensTableSQL = `
CREATE TABLE IF NOT EXISTS access_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
scopes TEXT DEFAULT 'read,write',
last_used_at DATETIME,
expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`;
db.run(createAccessTokensTableSQL, (err) => {
if (err) {
console.error('Error creating access tokens table:', err.message);
} else {
console.log('Access tokens table ready');
}
});
// Create cash accounts table
const createCashAccountsTableSQL = `
CREATE TABLE IF NOT EXISTS cash_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
account_name TEXT NOT NULL,
account_type TEXT DEFAULT 'savings' CHECK (account_type IN ('savings', 'checking', 'money_market', 'cd', 'other')),
balance REAL NOT NULL DEFAULT 0 CHECK (balance >= 0),
currency TEXT NOT NULL DEFAULT 'EUR' CHECK (currency IN ('EUR', 'USD')),
institution_name TEXT,
interest_rate REAL DEFAULT 0 CHECK (interest_rate >= 0),
notes TEXT,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`;
// Create cash transfers table
const createCashTransfersTableSQL = `
CREATE TABLE IF NOT EXISTS cash_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
transfer_type TEXT NOT NULL CHECK (transfer_type IN ('deposit', 'withdrawal')),
amount REAL NOT NULL CHECK (amount > 0),
description TEXT,
transfer_date DATE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES cash_accounts (id) ON DELETE CASCADE
)
`;
db.run(createCashAccountsTableSQL, (err) => {
if (err) {
console.error('Error creating cash accounts table:', err.message);
} else {
console.log('Cash accounts table ready');
}
});
db.run(createCashTransfersTableSQL, (err) => {
if (err) {
console.error('Error creating cash transfers table:', err.message);
} else {
console.log('Cash transfers table ready');
}
});
} }
async function createDefaultAdmin() { async function createDefaultAdmin() {
@ -167,6 +273,72 @@ function requireAdmin(req, res, next) {
} }
} }
// Middleware for token-based authentication
async function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// Extract token prefix (first 8 characters)
const tokenPrefix = token.substring(0, 8);
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
try {
const sql = `
SELECT at.*, u.username, u.is_admin
FROM access_tokens at
JOIN users u ON at.user_id = u.id
WHERE at.token_prefix = ? AND at.token_hash = ?
`;
db.get(sql, [tokenPrefix, tokenHash], (err, row) => {
if (err) {
console.error('Token authentication error:', err);
return res.status(500).json({ error: 'Authentication error' });
}
if (!row) {
return res.status(401).json({ error: 'Invalid access token' });
}
// Check if token is expired
if (row.expires_at && new Date() > new Date(row.expires_at)) {
return res.status(401).json({ error: 'Access token expired' });
}
// Update last used timestamp
db.run('UPDATE access_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [row.id]);
// Set user info for downstream middleware
req.session = req.session || {};
req.session.userId = row.user_id;
req.session.username = row.username;
req.session.isAdmin = row.is_admin === 1;
req.tokenAuth = true;
req.tokenScopes = row.scopes ? row.scopes.split(',') : ['read', 'write'];
next();
});
} catch (error) {
console.error('Token authentication error:', error);
res.status(500).json({ error: 'Authentication error' });
}
}
// Combined authentication middleware (session or token)
function requireAuthOrToken(req, res, next) {
// Check for token authentication first
if (req.headers['authorization']) {
return authenticateToken(req, res, next);
}
// Fall back to session authentication
return requireAuth(req, res, next);
}
// Authentication endpoints // Authentication endpoints
app.post('/api/login', async (req, res) => { app.post('/api/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
@ -220,7 +392,7 @@ app.post('/api/logout', (req, res) => {
}); });
}); });
app.get('/api/me', requireAuth, (req, res) => { app.get('/api/me', requireAuthOrToken, (req, res) => {
res.json({ res.json({
id: req.session.userId, id: req.session.userId,
username: req.session.username, username: req.session.username,
@ -306,7 +478,450 @@ app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
}); });
}); });
app.get('/api/trades', requireAuth, (req, res) => { // Access Token Management endpoints
app.get('/api/tokens', requireAuthOrToken, (req, res) => {
const sql = `
SELECT id, token_name, token_prefix, scopes, last_used_at, expires_at, created_at
FROM access_tokens
WHERE user_id = ?
ORDER BY created_at DESC
`;
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error('Error fetching tokens:', err.message);
res.status(500).json({ error: 'Failed to fetch access tokens' });
return;
}
res.json(rows || []);
});
});
app.post('/api/tokens', requireAuthOrToken, (req, res) => {
const { name, expires_in_days = null, scopes = 'read,write' } = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Token name is required' });
}
if (name.length > 50) {
return res.status(400).json({ error: 'Token name must be 50 characters or less' });
}
// Generate a secure token (32 bytes = 64 hex characters)
const token = crypto.randomBytes(32).toString('hex');
const tokenPrefix = token.substring(0, 8);
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// Calculate expiration date
const expiresAt = expires_in_days ? new Date(Date.now() + expires_in_days * 24 * 60 * 60 * 1000) : null;
const sql = `
INSERT INTO access_tokens (user_id, token_name, token_hash, token_prefix, scopes, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`;
db.run(sql, [req.session.userId, name.trim(), tokenHash, tokenPrefix, scopes, expiresAt], function(err) {
if (err) {
console.error('Error creating access token:', err.message);
res.status(500).json({ error: 'Failed to create access token' });
return;
}
res.status(201).json({
message: 'Access token created successfully',
token_id: this.lastID,
token: token, // Only returned once
token_name: name.trim(),
scopes: scopes,
expires_at: expiresAt
});
});
});
app.delete('/api/tokens/:id', requireAuthOrToken, (req, res) => {
const tokenId = req.params.id;
if (!tokenId || isNaN(tokenId)) {
return res.status(400).json({ error: 'Invalid token ID' });
}
const sql = 'DELETE FROM access_tokens WHERE id = ? AND user_id = ?';
db.run(sql, [tokenId, req.session.userId], function(err) {
if (err) {
console.error('Error deleting access token:', err.message);
res.status(500).json({ error: 'Failed to delete access token' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Access token not found' });
return;
}
res.json({ message: 'Access token deleted successfully' });
});
});
app.patch('/api/tokens/:id', requireAuthOrToken, (req, res) => {
const tokenId = req.params.id;
const { name } = req.body;
if (!tokenId || isNaN(tokenId)) {
return res.status(400).json({ error: 'Invalid token ID' });
}
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Token name is required' });
}
if (name.length > 50) {
return res.status(400).json({ error: 'Token name must be 50 characters or less' });
}
const sql = 'UPDATE access_tokens SET token_name = ? WHERE id = ? AND user_id = ?';
db.run(sql, [name.trim(), tokenId, req.session.userId], function(err) {
if (err) {
console.error('Error updating access token:', err.message);
res.status(500).json({ error: 'Failed to update access token' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Access token not found' });
return;
}
res.json({ message: 'Access token updated successfully' });
});
});
// Cash Accounts Management endpoints
app.get('/api/cash-accounts', requireAuthOrToken, (req, res) => {
const sql = `
SELECT id, account_name, account_type, balance, currency, institution_name,
interest_rate, notes, is_active, created_at, updated_at
FROM cash_accounts
WHERE user_id = ? AND is_active = 1
ORDER BY account_name ASC
`;
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error('Error fetching cash accounts:', err.message);
res.status(500).json({ error: 'Failed to fetch cash accounts' });
return;
}
res.json(rows || []);
});
});
app.post('/api/cash-accounts', requireAuthOrToken, (req, res) => {
const {
account_name,
account_type = 'savings',
balance = 0,
currency = 'EUR',
institution_name = '',
interest_rate = 0,
notes = ''
} = req.body;
if (!account_name || account_name.trim().length === 0) {
return res.status(400).json({ error: 'Account name is required' });
}
if (account_name.length > 100) {
return res.status(400).json({ error: 'Account name must be 100 characters or less' });
}
if (balance < 0) {
return res.status(400).json({ error: 'Balance cannot be negative' });
}
if (interest_rate < 0) {
return res.status(400).json({ error: 'Interest rate cannot be negative' });
}
const sql = `
INSERT INTO cash_accounts (
user_id, account_name, account_type, balance, currency,
institution_name, interest_rate, notes
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(sql, [
req.session.userId,
account_name.trim(),
account_type,
parseFloat(balance),
currency,
institution_name.trim(),
parseFloat(interest_rate),
notes.trim()
], function(err) {
if (err) {
console.error('Error creating cash account:', err.message);
res.status(500).json({ error: 'Failed to create cash account' });
return;
}
res.status(201).json({
message: 'Cash account created successfully',
account_id: this.lastID,
account_name: account_name.trim(),
account_type,
balance: parseFloat(balance),
currency
});
});
});
app.put('/api/cash-accounts/:id', requireAuthOrToken, (req, res) => {
const accountId = req.params.id;
const {
account_name,
account_type,
balance,
currency,
institution_name = '',
interest_rate = 0,
notes = ''
} = req.body;
if (!accountId || isNaN(accountId)) {
return res.status(400).json({ error: 'Invalid account ID' });
}
if (!account_name || account_name.trim().length === 0) {
return res.status(400).json({ error: 'Account name is required' });
}
if (account_name.length > 100) {
return res.status(400).json({ error: 'Account name must be 100 characters or less' });
}
if (balance < 0) {
return res.status(400).json({ error: 'Balance cannot be negative' });
}
if (interest_rate < 0) {
return res.status(400).json({ error: 'Interest rate cannot be negative' });
}
const sql = `
UPDATE cash_accounts
SET account_name = ?, account_type = ?, balance = ?, currency = ?,
institution_name = ?, interest_rate = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
`;
db.run(sql, [
account_name.trim(),
account_type,
parseFloat(balance),
currency,
institution_name.trim(),
parseFloat(interest_rate),
notes.trim(),
accountId,
req.session.userId
], function(err) {
if (err) {
console.error('Error updating cash account:', err.message);
res.status(500).json({ error: 'Failed to update cash account' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Cash account not found' });
return;
}
res.json({ message: 'Cash account updated successfully' });
});
});
app.delete('/api/cash-accounts/:id', requireAuthOrToken, (req, res) => {
const accountId = req.params.id;
if (!accountId || isNaN(accountId)) {
return res.status(400).json({ error: 'Invalid account ID' });
}
const sql = 'UPDATE cash_accounts SET is_active = 0 WHERE id = ? AND user_id = ?';
db.run(sql, [accountId, req.session.userId], function(err) {
if (err) {
console.error('Error deleting cash account:', err.message);
res.status(500).json({ error: 'Failed to delete cash account' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Cash account not found' });
return;
}
res.json({ message: 'Cash account deleted successfully' });
});
});
app.get('/api/cash-summary', requireAuthOrToken, (req, res) => {
const sql = `
SELECT
SUM(CASE WHEN currency = 'EUR' THEN balance ELSE 0 END) as total_eur,
SUM(CASE WHEN currency = 'USD' THEN balance ELSE 0 END) as total_usd,
COUNT(*) as account_count,
AVG(CASE WHEN interest_rate > 0 THEN interest_rate ELSE NULL END) as avg_interest_rate
FROM cash_accounts
WHERE user_id = ? AND is_active = 1
`;
db.get(sql, [req.session.userId], (err, row) => {
if (err) {
console.error('Error fetching cash summary:', err.message);
res.status(500).json({ error: 'Failed to fetch cash summary' });
return;
}
res.json({
total_eur: row.total_eur || 0,
total_usd: row.total_usd || 0,
account_count: row.account_count || 0,
avg_interest_rate: row.avg_interest_rate || 0
});
});
});
// Cash Transfers API endpoints
app.post('/api/cash-transfers', requireAuthOrToken, (req, res) => {
const {
account_id,
transfer_type,
amount,
description = '',
transfer_date
} = req.body;
if (!account_id || !transfer_type || !amount || !transfer_date) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!['deposit', 'withdrawal'].includes(transfer_type)) {
return res.status(400).json({ error: 'Invalid transfer type' });
}
if (amount <= 0) {
return res.status(400).json({ error: 'Amount must be greater than 0' });
}
// First check if account belongs to user
db.get('SELECT * FROM cash_accounts WHERE id = ? AND user_id = ? AND is_active = 1',
[account_id, req.session.userId],
(err, account) => {
if (err) {
console.error('Error checking account:', err.message);
return res.status(500).json({ error: 'Failed to verify account' });
}
if (!account) {
return res.status(404).json({ error: 'Account not found' });
}
// For withdrawals, check if sufficient balance
if (transfer_type === 'withdrawal' && account.balance < amount) {
return res.status(400).json({ error: 'Insufficient balance' });
}
// Begin transaction
db.serialize(() => {
db.run('BEGIN TRANSACTION');
// Insert transfer record
const insertTransferSQL = `
INSERT INTO cash_transfers (user_id, account_id, transfer_type, amount, description, transfer_date)
VALUES (?, ?, ?, ?, ?, ?)
`;
db.run(insertTransferSQL, [req.session.userId, account_id, transfer_type, amount, description, transfer_date],
function(err) {
if (err) {
console.error('Error inserting transfer:', err.message);
db.run('ROLLBACK');
return res.status(500).json({ error: 'Failed to create transfer' });
}
const transferId = this.lastID;
// Update account balance
const balanceChange = transfer_type === 'deposit' ? amount : -amount;
const updateBalanceSQL = `
UPDATE cash_accounts
SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
`;
db.run(updateBalanceSQL, [balanceChange, account_id, req.session.userId],
function(err) {
if (err) {
console.error('Error updating balance:', err.message);
db.run('ROLLBACK');
return res.status(500).json({ error: 'Failed to update balance' });
}
db.run('COMMIT');
res.json({
id: transferId,
account_id,
transfer_type,
amount,
description,
transfer_date,
message: 'Transfer created successfully'
});
});
});
});
});
});
app.get('/api/cash-transfers', requireAuthOrToken, (req, res) => {
const { account_id } = req.query;
let sql = `
SELECT ct.*, ca.account_name, ca.currency
FROM cash_transfers ct
JOIN cash_accounts ca ON ct.account_id = ca.id
WHERE ct.user_id = ?
`;
const params = [req.session.userId];
if (account_id) {
sql += ' AND ct.account_id = ?';
params.push(account_id);
}
sql += ' ORDER BY ct.transfer_date DESC, ct.created_at DESC';
db.all(sql, params, (err, rows) => {
if (err) {
console.error('Error fetching transfers:', err.message);
res.status(500).json({ error: 'Failed to fetch transfers' });
return;
}
res.json(rows);
});
});
app.get('/api/trades', requireAuthOrToken, (req, res) => {
const sql = 'SELECT * FROM trades WHERE user_id = ? ORDER BY trade_datetime DESC, created_at DESC'; const sql = 'SELECT * FROM trades WHERE user_id = ? ORDER BY trade_datetime DESC, created_at DESC';
db.all(sql, [req.session.userId], (err, rows) => { db.all(sql, [req.session.userId], (err, rows) => {
@ -334,7 +949,7 @@ app.get('/api/trades', requireAuth, (req, res) => {
}); });
}); });
app.post('/api/trades', requireAuth, (req, res) => { app.post('/api/trades', requireAuthOrToken, (req, res) => {
const { const {
etfSymbol, etfSymbol,
tradeType, tradeType,
@ -407,7 +1022,7 @@ app.post('/api/trades', requireAuth, (req, res) => {
}); });
}); });
app.delete('/api/trades/:id', requireAuth, (req, res) => { app.delete('/api/trades/:id', requireAuthOrToken, (req, res) => {
const tradeId = req.params.id; const tradeId = req.params.id;
if (!tradeId || isNaN(tradeId)) { if (!tradeId || isNaN(tradeId)) {
@ -432,7 +1047,7 @@ app.delete('/api/trades/:id', requireAuth, (req, res) => {
}); });
}); });
app.delete('/api/trades', requireAuth, (req, res) => { app.delete('/api/trades', requireAuthOrToken, (req, res) => {
const sql = 'DELETE FROM trades WHERE user_id = ?'; const sql = 'DELETE FROM trades WHERE user_id = ?';
db.run(sql, [req.session.userId], function(err) { db.run(sql, [req.session.userId], function(err) {
@ -450,7 +1065,7 @@ app.delete('/api/trades', requireAuth, (req, res) => {
}); });
// CGT Settings endpoints // CGT Settings endpoints
app.get('/api/cgt-settings', requireAuth, (req, res) => { app.get('/api/cgt-settings', requireAuthOrToken, (req, res) => {
const sql = 'SELECT * FROM cgt_settings WHERE user_id = ?'; const sql = 'SELECT * FROM cgt_settings WHERE user_id = ?';
db.get(sql, [req.session.userId], (err, row) => { db.get(sql, [req.session.userId], (err, row) => {
@ -468,6 +1083,7 @@ app.get('/api/cgt-settings', requireAuth, (req, res) => {
rate_1year: 30.0, rate_1year: 30.0,
rate_2years: 20.0, rate_2years: 20.0,
rate_longterm: 10.0, rate_longterm: 10.0,
rate_8years: 33.0,
annual_exemption: 1270.0, annual_exemption: 1270.0,
enabled: true enabled: true
}); });
@ -480,25 +1096,27 @@ app.get('/api/cgt-settings', requireAuth, (req, res) => {
rate_1year: row.rate_1year, rate_1year: row.rate_1year,
rate_2years: row.rate_2years, rate_2years: row.rate_2years,
rate_longterm: row.rate_longterm, rate_longterm: row.rate_longterm,
rate_8years: row.rate_8years,
annual_exemption: row.annual_exemption, annual_exemption: row.annual_exemption,
enabled: row.enabled === 1 enabled: row.enabled === 1
}); });
}); });
}); });
app.post('/api/cgt-settings', requireAuth, (req, res) => { app.post('/api/cgt-settings', requireAuthOrToken, (req, res) => {
const { const {
rate_1month = 40.0, rate_1month = 40.0,
rate_6months = 35.0, rate_6months = 35.0,
rate_1year = 30.0, rate_1year = 30.0,
rate_2years = 20.0, rate_2years = 20.0,
rate_longterm = 10.0, rate_longterm = 10.0,
rate_8years = 33.0,
annual_exemption = 1270.0, annual_exemption = 1270.0,
enabled = true enabled = true
} = req.body; } = req.body;
// Validate rates are between 0 and 100 // Validate rates are between 0 and 100
const rates = [rate_1month, rate_6months, rate_1year, rate_2years, rate_longterm]; const rates = [rate_1month, rate_6months, rate_1year, rate_2years, rate_longterm, rate_8years];
if (rates.some(rate => rate < 0 || rate > 100)) { if (rates.some(rate => rate < 0 || rate > 100)) {
return res.status(400).json({ error: 'CGT rates must be between 0 and 100' }); return res.status(400).json({ error: 'CGT rates must be between 0 and 100' });
} }
@ -509,8 +1127,8 @@ app.post('/api/cgt-settings', requireAuth, (req, res) => {
const sql = ` const sql = `
INSERT OR REPLACE INTO cgt_settings INSERT OR REPLACE INTO cgt_settings
(user_id, rate_1month, rate_6months, rate_1year, rate_2years, rate_longterm, annual_exemption, enabled, updated_at) (user_id, rate_1month, rate_6months, rate_1year, rate_2years, rate_longterm, rate_8years, annual_exemption, enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`; `;
const params = [ const params = [
@ -520,6 +1138,7 @@ app.post('/api/cgt-settings', requireAuth, (req, res) => {
rate_1year, rate_1year,
rate_2years, rate_2years,
rate_longterm, rate_longterm,
rate_8years,
annual_exemption, annual_exemption,
enabled ? 1 : 0 enabled ? 1 : 0
]; ];
@ -539,6 +1158,7 @@ app.post('/api/cgt-settings', requireAuth, (req, res) => {
rate_1year, rate_1year,
rate_2years, rate_2years,
rate_longterm, rate_longterm,
rate_8years,
annual_exemption, annual_exemption,
enabled enabled
} }
@ -546,7 +1166,7 @@ app.post('/api/cgt-settings', requireAuth, (req, res) => {
}); });
}); });
app.get('/api/portfolio-summary', requireAuth, (req, res) => { app.get('/api/portfolio-summary', requireAuthOrToken, (req, res) => {
const sql = ` const sql = `
SELECT SELECT
etf_symbol, etf_symbol,

View File

@ -535,6 +535,94 @@ body {
border-left-color: #dc3545; border-left-color: #dc3545;
} }
/* Total Holdings Card Styles */
.total-holdings-section {
margin: 20px 0;
}
.total-holdings-card {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
padding: 25px;
border-radius: 15px;
border-left: 4px solid #f39c12;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.total-holdings-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.total-holdings-card h3 {
margin: 0 0 20px 0;
font-size: 1.3rem;
color: #e67e22;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
text-align: center;
border-bottom: 2px solid #f39c12;
padding-bottom: 10px;
}
.holdings-breakdown {
display: flex;
flex-direction: column;
gap: 12px;
}
.holdings-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.holdings-label {
font-size: 14px;
color: #6c757d;
font-weight: 500;
}
.holdings-value {
font-size: 16px;
font-weight: 600;
color: #e67e22;
}
.holdings-divider {
height: 2px;
background: linear-gradient(90deg, #f39c12 0%, #e67e22 100%);
margin: 10px 0;
border-radius: 1px;
}
.holdings-total {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
background: rgba(243, 156, 18, 0.1);
border-radius: 8px;
padding: 15px 20px;
margin-top: 5px;
}
.holdings-total .holdings-label {
font-size: 16px;
font-weight: 600;
color: #d35400;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.holdings-total .holdings-value.total {
font-size: 20px;
font-weight: 700;
color: #d35400;
}
.etf-breakdown { .etf-breakdown {
margin-top: 20px; margin-top: 20px;
} }
@ -1798,4 +1886,895 @@ body {
.long-term-stat span:last-child { .long-term-stat span:last-child {
font-weight: 600; font-weight: 600;
color: #333; color: #333;
}
/* ===========================================
API Tokens Styles
=========================================== */
.tokens-container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.tokens-intro {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border-left: 4px solid #007bff;
}
.tokens-intro p {
margin: 0;
color: #555;
font-size: 0.95em;
line-height: 1.5;
}
.token-actions {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.create-token-form .form-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.create-token-form .form-group {
flex: 1;
}
.create-token-form label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.create-token-form input,
.create-token-form select {
width: 100%;
padding: 10px;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.create-token-form input:focus,
.create-token-form select:focus {
outline: none;
border-color: #007bff;
}
.create-token-btn {
background: #007bff;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.create-token-btn:hover {
background: #0056b3;
}
.tokens-list-section {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.tokens-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.token-item {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #28a745;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.token-info {
flex: 1;
}
.token-name {
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 16px;
}
.token-details {
display: flex;
flex-wrap: wrap;
gap: 15px;
font-size: 13px;
color: #666;
}
.token-detail {
display: flex;
align-items: center;
gap: 5px;
}
.token-prefix {
font-family: 'Courier New', monospace;
background: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.token-status {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.token-status.active {
background: #d4edda;
color: #155724;
}
.token-status.expired {
background: #f8d7da;
color: #721c24;
}
.token-actions-buttons {
display: flex;
gap: 8px;
}
.token-edit-btn,
.token-delete-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.token-edit-btn {
background: #ffc107;
color: #212529;
}
.token-edit-btn:hover {
background: #e0a800;
}
.token-delete-btn {
background: #dc3545;
color: white;
}
.token-delete-btn:hover {
background: #c82333;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 600px;
width: 90%;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.token-display {
display: flex;
margin: 20px 0;
gap: 10px;
}
.token-display input {
flex: 1;
padding: 10px;
border: 2px solid #28a745;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 14px;
background: #f8f9fa;
}
.copy-btn {
background: #28a745;
color: white;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.copy-btn:hover {
background: #218838;
}
.copy-btn.copied {
background: #17a2b8;
}
.token-usage {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.usage-example {
display: block;
background: #2d3748;
color: #e2e8f0;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
margin-top: 10px;
white-space: pre-wrap;
overflow-x: auto;
}
.close-modal-btn {
background: #6c757d;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
width: 100%;
margin-top: 20px;
transition: background-color 0.2s;
}
.close-modal-btn:hover {
background: #5a6268;
}
/* Responsive Design */
@media (max-width: 768px) {
.create-token-form .form-row {
flex-direction: column;
gap: 15px;
}
.token-item {
flex-direction: column;
gap: 15px;
}
.token-actions-buttons {
align-self: stretch;
}
.modal-content {
width: 95%;
padding: 20px;
margin: 20px;
}
}
/* ===========================================
Cash Accounts Styles
=========================================== */
.cash-accounts-container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.cash-intro {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border-left: 4px solid #28a745;
}
.cash-intro p {
margin: 0;
color: #555;
font-size: 0.95em;
line-height: 1.5;
}
.cash-summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.cash-summary-card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
border-left: 4px solid #28a745;
}
.cash-summary-card h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 16px;
}
.cash-amounts {
display: flex;
flex-direction: column;
gap: 8px;
}
.amount-line, .stat-line {
display: flex;
justify-content: space-between;
align-items: center;
}
.amount-line .currency {
font-weight: 500;
color: #666;
}
.amount-line .amount {
font-weight: 600;
font-size: 18px;
color: #28a745;
}
.stat-line span:first-child {
color: #666;
}
.stat-line span:last-child {
font-weight: 600;
color: #333;
}
.cash-actions {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.create-account-form .form-group.full-width {
grid-column: 1 / -1;
}
.create-account-form textarea {
width: 100%;
padding: 10px;
border: 2px solid #e1e5e9;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
.create-account-form textarea:focus {
outline: none;
border-color: #28a745;
}
.create-account-btn {
background: #28a745;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.create-account-btn:hover {
background: #218838;
}
.accounts-list-section {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.account-item {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #28a745;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.account-info {
flex: 1;
}
.account-name {
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 16px;
}
.account-type-badge {
display: inline-block;
background: #e9ecef;
color: #495057;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
margin-bottom: 8px;
}
.account-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
font-size: 13px;
color: #666;
}
.account-detail {
display: flex;
flex-direction: column;
}
.account-detail-label {
font-weight: 500;
color: #495057;
margin-bottom: 2px;
}
.account-balance {
font-weight: 600;
font-size: 18px;
color: #28a745;
}
.account-actions-buttons {
display: flex;
gap: 8px;
}
.account-edit-btn,
.account-delete-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.account-edit-btn {
background: #ffc107;
color: #212529;
}
.account-edit-btn:hover {
background: #e0a800;
}
.account-delete-btn {
background: #dc3545;
color: white;
}
.account-delete-btn:hover {
background: #c82333;
}
/* Modal buttons */
.modal-buttons {
display: flex;
gap: 15px;
margin-top: 20px;
}
.cancel-btn {
background: #6c757d;
color: white;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.cancel-btn:hover {
background: #5a6268;
}
.save-btn {
background: #28a745;
color: white;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.save-btn:hover {
background: #218838;
}
/* Transfers Section Styles */
.transfers-section {
margin-top: 40px;
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #eee;
}
.section-header h3 {
margin: 0;
color: #333;
font-size: 1.2rem;
}
.transfers-list {
max-height: 400px;
overflow-y: auto;
}
.transfer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.transfer-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.transfer-item:last-child {
margin-bottom: 0;
}
.transfer-info {
display: flex;
flex-direction: column;
flex: 1;
}
.transfer-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.transfer-account {
font-weight: 500;
color: #333;
font-size: 14px;
}
.transfer-type {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.transfer-type.deposit {
background: #d4edda;
color: #155724;
}
.transfer-type.withdrawal {
background: #f8d7da;
color: #721c24;
}
.transfer-details {
display: flex;
align-items: center;
gap: 15px;
}
.transfer-date {
font-size: 12px;
color: #666;
}
.transfer-description {
font-size: 12px;
color: #888;
font-style: italic;
}
.transfer-amount {
font-weight: 600;
font-size: 16px;
min-width: 80px;
text-align: right;
}
.transfer-amount.deposit {
color: #28a745;
}
.transfer-amount.withdrawal {
color: #dc3545;
}
.transfer-amount.deposit::before {
content: '+';
}
.transfer-amount.withdrawal::before {
content: '-';
}
/* Standalone Transfer Form Page Styles */
.transfer-form-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.transfer-intro {
background: #e8f4fd;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border-left: 4px solid #007bff;
}
.transfer-intro p {
margin: 0;
color: #555;
font-size: 0.95em;
line-height: 1.5;
}
.transfer-form {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.transfer-form h2 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.5rem;
border-bottom: 2px solid #eee;
padding-bottom: 15px;
}
.recent-transfers {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.recent-transfers h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.2rem;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
/* Responsive Design for Cash Accounts */
@media (max-width: 768px) {
.cash-summary-cards {
grid-template-columns: 1fr;
}
.account-item {
flex-direction: column;
gap: 15px;
}
.account-details {
grid-template-columns: 1fr;
gap: 8px;
}
.account-actions-buttons {
align-self: stretch;
}
}
/* ===========================================
Dashboard Cash Cards Styles
=========================================== */
.cash-breakdown {
margin-top: 20px;
}
.cash-breakdown h3 {
color: #333;
margin-bottom: 20px;
font-size: 1.3rem;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
.cash-summary-overview {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.cash-total-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.cash-amount-line {
display: flex;
align-items: center;
gap: 10px;
}
.currency-label {
color: #666;
font-weight: 500;
font-size: 14px;
min-width: 35px;
}
.cash-amount-line .amount {
font-weight: 600;
color: #28a745;
font-size: 16px;
}
.cash-stats-card {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.stat-detail {
color: #666;
font-size: 13px;
}
.cash-account-breakdown-item {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
transition: box-shadow 0.2s;
}
.cash-account-breakdown-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.cash-account-info {
display: flex;
flex-direction: column;
}
.cash-account-name {
font-weight: 500;
color: #333;
font-size: 14px;
margin-bottom: 2px;
}
.cash-account-type {
font-size: 12px;
color: #666;
}
.cash-account-balance {
font-weight: 600;
color: #28a745;
font-size: 14px;
}
/* Responsive Design for Dashboard Cash */
@media (max-width: 768px) {
.cash-summary-overview {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.cash-stats-card {
align-items: flex-start;
}
.cash-account-breakdown-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
} }