diff --git a/DATA-BACKUP-GUIDE.md b/DATA-BACKUP-GUIDE.md new file mode 100644 index 0000000..b8638fb --- /dev/null +++ b/DATA-BACKUP-GUIDE.md @@ -0,0 +1,122 @@ +# Data Backup and Restore Guide + +## Overview + +Your traceability application uses Docker volumes for persistent data storage. Data survives container restarts, updates, and redeployments automatically. + +## Data Location + +- **Docker Volume**: `traceability_traceability_data` +- **Backup Directory**: `/opt/traceability-backups` +- **Data Files**: JSON files stored in the volume + - `auth.json` - User accounts and authentication + - `budget.json` - Transactions, founders, settings, targets + - `planning.json` - Sprints, tasks, comments, time logs + - `documentation.json` - Documents and categories + +## Backup Commands + +### Create a Backup +```bash +cd /opt/traceability +./deploy.sh --backup +``` + +### Manual Backup +```bash +mkdir -p /opt/traceability-backups +docker run --rm \ + -v traceability_traceability_data:/data \ + -v /opt/traceability-backups:/backup \ + alpine tar -czf /backup/backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /data . +``` + +## Restore Commands + +### Restore from Backup +```bash +cd /opt/traceability +./deploy.sh --restore /opt/traceability-backups/backup_YYYYMMDD_HHMMSS.tar.gz +``` + +### Manual Restore +```bash +docker run --rm \ + -v traceability_traceability_data:/data \ + -v /opt/traceability-backups:/backup \ + alpine sh -c "rm -rf /data/* && tar -xzf /backup/backup_YYYYMMDD_HHMMSS.tar.gz -C /data" +``` + +## Deployment with Data Preservation + +### Standard Deployment (with automatic backup) +```bash +cd /opt/traceability +./deploy.sh +``` + +### Quick Deployment (skip backup) +```bash +./deploy.sh --skip-backup +``` + +## Data Migration + +### Export Data from One Server +```bash +# On source server +./deploy.sh --backup +scp /opt/traceability-backups/backup_*.tar.gz user@new-server:/tmp/ +``` + +### Import Data to New Server +```bash +# On destination server +./deploy.sh --restore /tmp/backup_YYYYMMDD_HHMMSS.tar.gz +./deploy.sh --skip-backup +``` + +## Automatic Backups (Cron) + +Add to crontab for daily backups: +```bash +# Edit crontab +crontab -e + +# Add this line for daily backup at 2 AM +0 2 * * * /opt/traceability/deploy.sh --backup >> /var/log/traceability-backup.log 2>&1 +``` + +## Viewing Data + +### Inspect Volume Contents +```bash +docker run --rm -v traceability_traceability_data:/data alpine ls -la /data +``` + +### View Specific Data File +```bash +docker run --rm -v traceability_traceability_data:/data alpine cat /data/auth.json +``` + +## Troubleshooting + +### Data Not Persisting +1. Check if volume exists: `docker volume ls | grep traceability` +2. Inspect volume: `docker volume inspect traceability_traceability_data` +3. Check data service logs: `docker logs data_service` + +### Restore Failed +1. Ensure backup file exists and is readable +2. Stop services first: `docker compose down` +3. Try manual restore (see above) +4. Restart services: `docker compose up -d` + +### Volume Full +```bash +# Check volume size +docker system df -v + +# Clean old backups +ls -t /opt/traceability-backups/backup_*.tar.gz | tail -n +11 | xargs rm -- +``` diff --git a/data-service/Dockerfile b/data-service/Dockerfile new file mode 100644 index 0000000..83aea13 --- /dev/null +++ b/data-service/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install Python and pip for running traceability script +RUN apk add --no-cache python3 py3-pip + +# Install Python dependencies for the traceability script +RUN pip3 install --break-system-packages requests pandas + +# Copy package files +COPY package*.json ./ + +# Install Node.js dependencies +RUN npm install --production + +# Copy source code +COPY . . + +# Create directories +RUN mkdir -p /data /scripts /srv/data + +# Expose port +EXPOSE 3002 + +# Start the service +CMD ["npm", "start"] diff --git a/data-service/index.js b/data-service/index.js new file mode 100644 index 0000000..f2907ab --- /dev/null +++ b/data-service/index.js @@ -0,0 +1,744 @@ +const express = require('express'); +const cors = require('cors'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const { spawn } = require('child_process'); + +const app = express(); +const PORT = process.env.PORT || 3002; +const DATA_DIR = process.env.DATA_DIR || '/data'; +const SCRIPTS_DIR = process.env.SCRIPTS_DIR || '/scripts'; +const PUBLIC_DIR = process.env.PUBLIC_DIR || '/srv/data'; + +// Middleware +app.use(cors()); +app.use(express.json({ limit: '50mb' })); + +// Helper functions +const getFilePath = (collection) => path.join(DATA_DIR, `${collection}.json`); + +const readCollection = async (collection) => { + const filePath = getFilePath(collection); + try { + const data = await fs.readFile(filePath, 'utf8'); + return JSON.parse(data); + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } +}; + +const writeCollection = async (collection, data) => { + const filePath = getFilePath(collection); + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8'); +}; + +// Simple hash function for passwords +const simpleHash = (str) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return hash.toString(36); +}; + +// Initialize default data +const initializeDefaults = async () => { + // Initialize auth data with default admin + const authData = await readCollection('auth'); + if (!authData) { + const defaultAuth = { + users: [ + { + id: 'admin-1', + username: 'admin', + email: 'support@nabd-co.com', + password: simpleHash('admin123'), + role: 'admin', + status: 'approved', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }; + await writeCollection('auth', defaultAuth); + console.log('Initialized default auth data'); + } + + // Initialize budget data + const budgetData = await readCollection('budget'); + if (!budgetData) { + const defaultBudget = { + transactions: [], + founders: [], + settings: { + currency: 'USD', + fiscalYearStart: 1, + runwayWarningMonths: 6, + }, + targets: [], + recurringTransactions: [], + }; + await writeCollection('budget', defaultBudget); + console.log('Initialized default budget data'); + } + + // Initialize planning data + const planningData = await readCollection('planning'); + if (!planningData) { + const defaultPlanning = { + sprints: [], + tasks: [], + comments: [], + timeLogs: [], + }; + await writeCollection('planning', defaultPlanning); + console.log('Initialized default planning data'); + } + + // Initialize documentation data + const docsData = await readCollection('documentation'); + if (!docsData) { + const defaultDocs = { + documents: [], + categories: ['Architecture', 'API', 'User Guide', 'Development'], + }; + await writeCollection('documentation', defaultDocs); + console.log('Initialized default documentation data'); + } +}; + +// ==================== GENERIC CRUD ROUTES ==================== + +// Get entire collection +app.get('/api/:collection', async (req, res) => { + try { + const { collection } = req.params; + const data = await readCollection(collection); + res.json(data || {}); + } catch (error) { + console.error(`Error reading ${req.params.collection}:`, error); + res.status(500).json({ error: 'Failed to read data' }); + } +}); + +// Update entire collection +app.put('/api/:collection', async (req, res) => { + try { + const { collection } = req.params; + await writeCollection(collection, req.body); + res.json({ success: true }); + } catch (error) { + console.error(`Error writing ${req.params.collection}:`, error); + res.status(500).json({ error: 'Failed to write data' }); + } +}); + +// ==================== AUTH ROUTES ==================== + +app.post('/api/auth/login', async (req, res) => { + try { + const { username, password } = req.body; + const authData = await readCollection('auth'); + const hashedPassword = simpleHash(password); + + const user = authData.users.find( + u => u.username === username && u.password === hashedPassword + ); + + if (!user) { + return res.json({ success: false, message: 'Invalid username or password' }); + } + + if (user.status === 'pending') { + return res.json({ success: false, message: 'Your account is pending approval.' }); + } + + if (user.status === 'rejected') { + return res.json({ success: false, message: 'Your account was not approved.' }); + } + + // Remove password from response + const { password: _, ...safeUser } = user; + res.json({ success: true, message: 'Login successful', user: safeUser }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ success: false, message: 'Server error' }); + } +}); + +app.post('/api/auth/register', async (req, res) => { + try { + const { username, email, password } = req.body; + const authData = await readCollection('auth'); + + if (authData.users.some(u => u.username === username)) { + return res.json({ success: false, message: 'Username already taken' }); + } + + if (authData.users.some(u => u.email === email)) { + return res.json({ success: false, message: 'Email already registered' }); + } + + const newUser = { + id: uuidv4(), + username, + email, + password: simpleHash(password), + role: 'member', + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + authData.users.push(newUser); + await writeCollection('auth', authData); + + res.json({ success: true, message: 'Registration successful! Please wait for admin approval.' }); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ success: false, message: 'Server error' }); + } +}); + +app.get('/api/auth/users', async (req, res) => { + try { + const authData = await readCollection('auth'); + // Remove passwords from response + const safeUsers = authData.users.map(({ password, ...user }) => user); + res.json(safeUsers); + } catch (error) { + console.error('Get users error:', error); + res.status(500).json({ error: 'Failed to get users' }); + } +}); + +app.put('/api/auth/users/:userId', async (req, res) => { + try { + const { userId } = req.params; + const updates = req.body; + const authData = await readCollection('auth'); + + const index = authData.users.findIndex(u => u.id === userId); + if (index === -1) { + return res.status(404).json({ error: 'User not found' }); + } + + // Hash password if being updated + if (updates.password) { + updates.password = simpleHash(updates.password); + } + + authData.users[index] = { + ...authData.users[index], + ...updates, + updatedAt: new Date().toISOString(), + }; + + await writeCollection('auth', authData); + + const { password, ...safeUser } = authData.users[index]; + res.json(safeUser); + } catch (error) { + console.error('Update user error:', error); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +app.delete('/api/auth/users/:userId', async (req, res) => { + try { + const { userId } = req.params; + const authData = await readCollection('auth'); + + const user = authData.users.find(u => u.id === userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Prevent deleting last admin + const admins = authData.users.filter(u => u.role === 'admin' && u.status === 'approved'); + if (user.role === 'admin' && admins.length === 1) { + return res.status(400).json({ error: 'Cannot delete the last admin' }); + } + + authData.users = authData.users.filter(u => u.id !== userId); + await writeCollection('auth', authData); + + res.json({ success: true }); + } catch (error) { + console.error('Delete user error:', error); + res.status(500).json({ error: 'Failed to delete user' }); + } +}); + +app.post('/api/auth/users', async (req, res) => { + try { + const { username, email, password, role } = req.body; + const authData = await readCollection('auth'); + + if (authData.users.some(u => u.username === username)) { + return res.status(400).json({ error: 'Username already taken' }); + } + + if (authData.users.some(u => u.email === email)) { + return res.status(400).json({ error: 'Email already registered' }); + } + + const newUser = { + id: uuidv4(), + username, + email, + password: simpleHash(password), + role: role || 'member', + status: 'approved', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + authData.users.push(newUser); + await writeCollection('auth', authData); + + const { password: _, ...safeUser } = newUser; + res.json(safeUser); + } catch (error) { + console.error('Add user error:', error); + res.status(500).json({ error: 'Failed to add user' }); + } +}); + +// ==================== BUDGET ROUTES ==================== + +app.get('/api/budget/transactions', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + res.json(budgetData?.transactions || []); + } catch (error) { + res.status(500).json({ error: 'Failed to get transactions' }); + } +}); + +app.post('/api/budget/transactions', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + const newTransaction = { + ...req.body, + id: uuidv4(), + createdAt: new Date().toISOString(), + }; + budgetData.transactions.push(newTransaction); + await writeCollection('budget', budgetData); + res.json(newTransaction); + } catch (error) { + res.status(500).json({ error: 'Failed to add transaction' }); + } +}); + +app.put('/api/budget/transactions/:id', async (req, res) => { + try { + const { id } = req.params; + const budgetData = await readCollection('budget'); + const index = budgetData.transactions.findIndex(t => t.id === id); + if (index === -1) { + return res.status(404).json({ error: 'Transaction not found' }); + } + budgetData.transactions[index] = { ...budgetData.transactions[index], ...req.body }; + await writeCollection('budget', budgetData); + res.json(budgetData.transactions[index]); + } catch (error) { + res.status(500).json({ error: 'Failed to update transaction' }); + } +}); + +app.delete('/api/budget/transactions/:id', async (req, res) => { + try { + const { id } = req.params; + const budgetData = await readCollection('budget'); + budgetData.transactions = budgetData.transactions.filter(t => t.id !== id); + await writeCollection('budget', budgetData); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete transaction' }); + } +}); + +// Founders +app.get('/api/budget/founders', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + res.json(budgetData?.founders || []); + } catch (error) { + res.status(500).json({ error: 'Failed to get founders' }); + } +}); + +app.post('/api/budget/founders', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + const newFounder = { ...req.body, id: uuidv4() }; + budgetData.founders.push(newFounder); + await writeCollection('budget', budgetData); + res.json(newFounder); + } catch (error) { + res.status(500).json({ error: 'Failed to add founder' }); + } +}); + +app.put('/api/budget/founders/:id', async (req, res) => { + try { + const { id } = req.params; + const budgetData = await readCollection('budget'); + const index = budgetData.founders.findIndex(f => f.id === id); + if (index === -1) { + return res.status(404).json({ error: 'Founder not found' }); + } + budgetData.founders[index] = { ...budgetData.founders[index], ...req.body }; + await writeCollection('budget', budgetData); + res.json(budgetData.founders[index]); + } catch (error) { + res.status(500).json({ error: 'Failed to update founder' }); + } +}); + +app.delete('/api/budget/founders/:id', async (req, res) => { + try { + const { id } = req.params; + const budgetData = await readCollection('budget'); + budgetData.founders = budgetData.founders.filter(f => f.id !== id); + await writeCollection('budget', budgetData); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete founder' }); + } +}); + +// Settings +app.get('/api/budget/settings', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + res.json(budgetData?.settings || { currency: 'USD', fiscalYearStart: 1, runwayWarningMonths: 6 }); + } catch (error) { + res.status(500).json({ error: 'Failed to get settings' }); + } +}); + +app.put('/api/budget/settings', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + budgetData.settings = { ...budgetData.settings, ...req.body }; + await writeCollection('budget', budgetData); + res.json(budgetData.settings); + } catch (error) { + res.status(500).json({ error: 'Failed to update settings' }); + } +}); + +// Targets +app.get('/api/budget/targets', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + res.json(budgetData?.targets || []); + } catch (error) { + res.status(500).json({ error: 'Failed to get targets' }); + } +}); + +app.put('/api/budget/targets', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + budgetData.targets = req.body; + await writeCollection('budget', budgetData); + res.json(budgetData.targets); + } catch (error) { + res.status(500).json({ error: 'Failed to update targets' }); + } +}); + +// Recurring Transactions +app.get('/api/budget/recurring', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + res.json(budgetData?.recurringTransactions || []); + } catch (error) { + res.status(500).json({ error: 'Failed to get recurring transactions' }); + } +}); + +app.post('/api/budget/recurring', async (req, res) => { + try { + const budgetData = await readCollection('budget'); + const newRecurring = { ...req.body, id: uuidv4() }; + budgetData.recurringTransactions = budgetData.recurringTransactions || []; + budgetData.recurringTransactions.push(newRecurring); + await writeCollection('budget', budgetData); + res.json(newRecurring); + } catch (error) { + res.status(500).json({ error: 'Failed to add recurring transaction' }); + } +}); + +app.put('/api/budget/recurring/:id', async (req, res) => { + try { + const { id } = req.params; + const budgetData = await readCollection('budget'); + const index = budgetData.recurringTransactions.findIndex(r => r.id === id); + if (index === -1) { + return res.status(404).json({ error: 'Recurring transaction not found' }); + } + budgetData.recurringTransactions[index] = { ...budgetData.recurringTransactions[index], ...req.body }; + await writeCollection('budget', budgetData); + res.json(budgetData.recurringTransactions[index]); + } catch (error) { + res.status(500).json({ error: 'Failed to update recurring transaction' }); + } +}); + +app.delete('/api/budget/recurring/:id', async (req, res) => { + try { + const { id } = req.params; + const budgetData = await readCollection('budget'); + budgetData.recurringTransactions = budgetData.recurringTransactions.filter(r => r.id !== id); + await writeCollection('budget', budgetData); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete recurring transaction' }); + } +}); + +// ==================== TRACEABILITY ROUTES ==================== + +// Run Python script to fetch and export traceability data +app.post('/api/traceability/sync', async (req, res) => { + console.log('[Traceability] Sync requested...'); + + const scriptPath = path.join(SCRIPTS_DIR, 'get_traceability.py'); + const outputPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); + + // Check if script exists + try { + await fs.access(scriptPath); + } catch { + console.log('[Traceability] Script not found at:', scriptPath); + return res.status(404).json({ + error: 'Python script not found', + details: `Expected at: ${scriptPath}`, + hint: 'Ensure get_traceability.py is mounted to /scripts in the container' + }); + } + + try { + // Run the Python script + const pythonProcess = spawn('python3', [scriptPath], { + cwd: PUBLIC_DIR, + env: { ...process.env } + }); + + let stdout = ''; + let stderr = ''; + + pythonProcess.stdout.on('data', (data) => { + stdout += data.toString(); + console.log('[Python]', data.toString().trim()); + }); + + pythonProcess.stderr.on('data', (data) => { + stderr += data.toString(); + console.error('[Python Error]', data.toString().trim()); + }); + + pythonProcess.on('close', async (code) => { + if (code !== 0) { + return res.status(500).json({ + error: 'Script execution failed', + code, + stdout, + stderr + }); + } + + // Read the generated CSV + try { + const csvData = await fs.readFile(outputPath, 'utf8'); + console.log(`[Traceability] CSV generated: ${csvData.length} bytes`); + + res.json({ + success: true, + message: 'Traceability data synced successfully', + csvLength: csvData.length, + stdout + }); + } catch (readErr) { + res.status(500).json({ + error: 'Failed to read generated CSV', + details: readErr.message + }); + } + }); + + } catch (error) { + console.error('[Traceability] Sync error:', error); + res.status(500).json({ error: 'Failed to run sync', details: error.message }); + } +}); + +// Get traceability data (returns CSV content) +app.get('/api/traceability', async (req, res) => { + const csvPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); + + try { + const csvData = await fs.readFile(csvPath, 'utf8'); + res.setHeader('Content-Type', 'text/csv'); + res.send(csvData); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ + error: 'Traceability data not found', + hint: 'Run POST /api/traceability/sync first or upload CSV manually' + }); + } + res.status(500).json({ error: 'Failed to read traceability data' }); + } +}); + +// Get traceability data as JSON (parsed) +app.get('/api/traceability/json', async (req, res) => { + const csvPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); + + try { + const csvData = await fs.readFile(csvPath, 'utf8'); + const workPackages = parseCSVToWorkPackages(csvData); + res.json({ workPackages, count: workPackages.length }); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Traceability data not found' }); + } + res.status(500).json({ error: 'Failed to read traceability data' }); + } +}); + +// Upload CSV directly +app.post('/api/traceability/upload', async (req, res) => { + const { csvContent } = req.body; + + if (!csvContent) { + return res.status(400).json({ error: 'csvContent is required' }); + } + + const csvPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); + + try { + await fs.mkdir(PUBLIC_DIR, { recursive: true }); + await fs.writeFile(csvPath, csvContent, 'utf8'); + + const workPackages = parseCSVToWorkPackages(csvContent); + + res.json({ + success: true, + message: 'CSV uploaded successfully', + workPackages: workPackages.length + }); + } catch (error) { + res.status(500).json({ error: 'Failed to save CSV', details: error.message }); + } +}); + +// Helper: Parse CSV to work packages +function parseCSVToWorkPackages(csvText) { + const cleanText = csvText.replace(/^\uFEFF/, ''); + const workPackages = []; + const lines = cleanText.split('\n'); + + let currentRow = []; + let inQuotedField = false; + let currentField = ''; + + const content = lines.slice(1).join('\n'); + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + const nextChar = content[i + 1]; + + if (inQuotedField) { + if (char === '"') { + if (nextChar === '"') { + currentField += '"'; + i++; + } else { + inQuotedField = false; + } + } else { + currentField += char; + } + } else { + if (char === '"' && currentField === '') { + inQuotedField = true; + } else if (char === ',') { + currentRow.push(currentField); + currentField = ''; + } else if (char === '\n') { + currentRow.push(currentField); + currentField = ''; + + if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) { + const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow; + workPackages.push({ + id: parseInt(id, 10), + type: type?.toLowerCase().replace(/\s+/g, ' ').trim(), + status: status || '', + title: title || '', + description: description || '', + parentId: parentId?.trim() || '', + relations: relations?.trim() || '' + }); + } + + currentRow = []; + } else { + currentField += char; + } + } + } + + // Handle last row + if (currentField || currentRow.length > 0) { + currentRow.push(currentField); + if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) { + const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow; + workPackages.push({ + id: parseInt(id, 10), + type: type?.toLowerCase().replace(/\s+/g, ' ').trim(), + status: status || '', + title: title || '', + description: description || '', + parentId: parentId?.trim() || '', + relations: relations?.trim() || '' + }); + } + } + + return workPackages; +} + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Start server +initializeDefaults().then(() => { + app.listen(PORT, () => { + console.log(`Data service running on port ${PORT}`); + console.log(`Data directory: ${DATA_DIR}`); + console.log(`Scripts directory: ${SCRIPTS_DIR}`); + console.log(`Public directory: ${PUBLIC_DIR}`); + }); +}).catch(error => { + console.error('Failed to initialize:', error); + process.exit(1); +}); diff --git a/data-service/package.json b/data-service/package.json new file mode 100644 index 0000000..4512856 --- /dev/null +++ b/data-service/package.json @@ -0,0 +1,14 @@ +{ + "name": "data-service", + "version": "1.0.0", + "description": "Persistent data storage API for Traceability Matrix", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "uuid": "^9.0.0" + } +} diff --git a/deploy.sh b/deploy.sh index fd1637e..60a90fc 100644 --- a/deploy.sh +++ b/deploy.sh @@ -7,6 +7,10 @@ # using Docker Compose with Caddy reverse proxy # # Domain: Traceability.nabd-co.com +# Features: +# - Persistent data storage via Docker volumes +# - Automatic data backup before updates +# - Multi-service architecture (web, data, email) # ============================================ set -e @@ -22,7 +26,8 @@ NC='\033[0m' # No Color APP_NAME="traceability" APP_DIR="/opt/traceability" CADDY_DIR="/root/caddy" -REPO_URL="" # Add your git repo URL if using git deployment +BACKUP_DIR="/opt/traceability-backups" +VOLUME_NAME="traceability_traceability_data" echo -e "${BLUE}============================================${NC}" echo -e "${BLUE} ASF Traceability Matrix Deployment${NC}" @@ -47,6 +52,110 @@ if [ "$EUID" -ne 0 ]; then exit 1 fi +# Parse command line arguments +BACKUP_ONLY=false +RESTORE_BACKUP="" +SKIP_BACKUP=false + +while [[ $# -gt 0 ]]; do + case $1 in + --backup) + BACKUP_ONLY=true + shift + ;; + --restore) + RESTORE_BACKUP="$2" + shift 2 + ;; + --skip-backup) + SKIP_BACKUP=true + shift + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --backup Create a backup only (no deployment)" + echo " --restore FILE Restore from a specific backup file" + echo " --skip-backup Skip backup before deployment" + echo " --help Show this help message" + exit 0 + ;; + *) + print_error "Unknown option: $1" + exit 1 + ;; + esac +done + +# Function to backup data +backup_data() { + echo "" + echo -e "${BLUE}Creating data backup...${NC}" + + mkdir -p $BACKUP_DIR + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + BACKUP_FILE="$BACKUP_DIR/backup_$TIMESTAMP.tar.gz" + + # Check if volume exists and has data + if docker volume ls | grep -q "$VOLUME_NAME"; then + # Create a temporary container to access the volume + docker run --rm -v $VOLUME_NAME:/data -v $BACKUP_DIR:/backup alpine \ + tar -czf /backup/backup_$TIMESTAMP.tar.gz -C /data . 2>/dev/null || true + + if [ -f "$BACKUP_FILE" ]; then + BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + print_status "Backup created: $BACKUP_FILE ($BACKUP_SIZE)" + + # Keep only last 10 backups + ls -t $BACKUP_DIR/backup_*.tar.gz 2>/dev/null | tail -n +11 | xargs -r rm -- + print_status "Cleaned old backups (keeping last 10)" + else + print_warning "No existing data to backup" + fi + else + print_warning "No existing data volume found - skipping backup" + fi +} + +# Function to restore data +restore_data() { + local backup_file=$1 + + if [ ! -f "$backup_file" ]; then + print_error "Backup file not found: $backup_file" + exit 1 + fi + + echo "" + echo -e "${BLUE}Restoring data from backup...${NC}" + + # Ensure volume exists + docker volume create $VOLUME_NAME 2>/dev/null || true + + # Restore data + docker run --rm -v $VOLUME_NAME:/data -v $(dirname $backup_file):/backup alpine \ + sh -c "rm -rf /data/* && tar -xzf /backup/$(basename $backup_file) -C /data" + + print_status "Data restored from: $backup_file" +} + +# Handle backup only mode +if [ "$BACKUP_ONLY" = true ]; then + backup_data + echo "" + echo -e "${GREEN}Backup complete!${NC}" + exit 0 +fi + +# Handle restore mode +if [ -n "$RESTORE_BACKUP" ]; then + restore_data "$RESTORE_BACKUP" + echo "" + echo -e "${GREEN}Restore complete! Run deploy.sh again to start services.${NC}" + exit 0 +fi + # Step 1: Create application directory echo "" echo -e "${BLUE}Step 1: Setting up application directory...${NC}" @@ -65,48 +174,76 @@ fi cd $APP_DIR -# Step 3: Ensure Caddy network exists +# Step 3: Backup existing data (unless skipped) +if [ "$SKIP_BACKUP" = false ]; then + backup_data +fi + +# Step 4: Ensure Caddy network exists echo "" -echo -e "${BLUE}Step 3: Checking Docker network...${NC}" -if ! docker network ls | grep -q "caddy_default"; then +echo -e "${BLUE}Step 4: Checking Docker network...${NC}" +if ! docker network ls | grep -q "caddy_network"; then print_warning "Caddy network not found. Creating..." - docker network create caddy_default - print_status "Created caddy_default network" + docker network create caddy_network + print_status "Created caddy_network network" else print_status "Caddy network exists" fi -# Step 4: Show Caddy configuration to add +# Step 5: Show Caddy configuration to add echo "" -echo -e "${BLUE}Step 4: Caddy configuration...${NC}" +echo -e "${BLUE}Step 5: Caddy configuration...${NC}" # Check if Traceability entry already exists in Caddyfile -if grep -q "Traceability.nabd-co.com" "$CADDY_DIR/Caddyfile" 2>/dev/null; then +if grep -q "traceability.nabd-co.com" "$CADDY_DIR/Caddyfile" 2>/dev/null; then print_status "Caddy configuration already exists in Caddyfile" else - print_warning "Add this entry to your Caddyfile at $CADDY_DIR/Caddyfile:" + print_warning "Add these entries to your Caddyfile at $CADDY_DIR/Caddyfile:" echo "" echo -e "${YELLOW}# -------------------------" echo "# Traceability Matrix Proxy" echo "# -------------------------" - echo "Traceability.nabd-co.com {" + echo "traceability.nabd-co.com {" echo " reverse_proxy traceability_web:8088" echo " encode gzip" + echo "}" + echo "" + echo "# Traceability Data API" + echo "traceability-api.nabd-co.com {" + echo " reverse_proxy data_service:3002" + echo " encode gzip" echo -e "}${NC}" echo "" fi -# Step 5: Build and start the application +# Step 6: Build and start the application echo "" -echo -e "${BLUE}Step 5: Building and starting application...${NC}" +echo -e "${BLUE}Step 6: Building and starting application...${NC}" docker compose down --remove-orphans 2>/dev/null || true docker compose build --no-cache docker compose up -d print_status "Application started" -# Step 6: Reload Caddy +# Step 7: Wait for services to be ready echo "" -echo -e "${BLUE}Step 6: Reloading Caddy...${NC}" +echo -e "${BLUE}Step 7: Waiting for services to initialize...${NC}" +sleep 5 + +# Check data service health +for i in {1..10}; do + if docker exec data_service wget -q -O - http://localhost:3002/health >/dev/null 2>&1; then + print_status "Data service is healthy" + break + fi + if [ $i -eq 10 ]; then + print_warning "Data service health check timed out (may still be starting)" + fi + sleep 2 +done + +# Step 8: Reload Caddy +echo "" +echo -e "${BLUE}Step 8: Reloading Caddy...${NC}" cd $CADDY_DIR docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || { print_warning "Could not reload Caddy automatically. Restarting container..." @@ -114,15 +251,24 @@ docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/n } print_status "Caddy reloaded" -# Step 7: Health check +# Step 9: Health check echo "" -echo -e "${BLUE}Step 7: Running health check...${NC}" -sleep 5 +echo -e "${BLUE}Step 9: Running health check...${NC}" -if docker ps | grep -q "traceability_web"; then - print_status "Container is running" -else - print_error "Container failed to start. Check logs with: docker logs traceability_web" +SERVICES=("traceability_web" "data_service" "email_service") +ALL_RUNNING=true + +for service in "${SERVICES[@]}"; do + if docker ps | grep -q "$service"; then + print_status "$service is running" + else + print_error "$service failed to start" + ALL_RUNNING=false + fi +done + +if [ "$ALL_RUNNING" = false ]; then + print_error "Some services failed to start. Check logs with: docker logs " exit 1 fi @@ -132,11 +278,22 @@ echo -e "${GREEN}============================================${NC}" echo -e "${GREEN} Deployment Complete!${NC}" echo -e "${GREEN}============================================${NC}" echo "" -echo -e "Application URL: ${BLUE}https://Traceability.nabd-co.com${NC}" +echo -e "Application URL: ${BLUE}https://traceability.nabd-co.com${NC}" +echo -e "API URL: ${BLUE}https://traceability-api.nabd-co.com${NC}" echo "" -echo -e "Useful commands:" -echo -e " View logs: ${YELLOW}docker logs -f traceability_web${NC}" -echo -e " Restart: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml restart${NC}" -echo -e " Stop: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml down${NC}" -echo -e " Rebuild: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml up -d --build${NC}" +echo -e "${YELLOW}Data Persistence:${NC}" +echo -e " Data Volume: ${BLUE}$VOLUME_NAME${NC}" +echo -e " Backup Dir: ${BLUE}$BACKUP_DIR${NC}" +echo "" +echo -e "${YELLOW}Useful commands:${NC}" +echo -e " View logs: ${BLUE}docker logs -f traceability_web${NC}" +echo -e " Data logs: ${BLUE}docker logs -f data_service${NC}" +echo -e " Backup data: ${BLUE}$APP_DIR/deploy.sh --backup${NC}" +echo -e " Restore data: ${BLUE}$APP_DIR/deploy.sh --restore ${NC}" +echo -e " Restart: ${BLUE}docker compose -f $APP_DIR/docker-compose.yml restart${NC}" +echo -e " Stop: ${BLUE}docker compose -f $APP_DIR/docker-compose.yml down${NC}" +echo -e " Rebuild: ${BLUE}docker compose -f $APP_DIR/docker-compose.yml up -d --build${NC}" +echo "" +echo -e "${GREEN}Note: Your data is stored in a Docker volume and will persist${NC}" +echo -e "${GREEN}across container restarts and redeployments.${NC}" echo "" diff --git a/docker-compose.yml b/docker-compose.yml index aa483ab..cd3be12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,52 @@ services: dockerfile: Dockerfile container_name: traceability_web restart: always + environment: + - VITE_API_URL=http://data-service:3002 + - VITE_EMAIL_SERVICE_URL=http://email-service:3001 networks: - caddy_network + depends_on: + - data-service + - email-service + + data-service: + build: + context: ./data-service + dockerfile: Dockerfile + container_name: data_service + restart: always + environment: + - PORT=3002 + - DATA_DIR=/data + - SCRIPTS_DIR=/scripts + - PUBLIC_DIR=/srv/data + volumes: + - traceability_data:/data + - ./public/data:/srv/data + - ./public/data:/scripts + networks: + - caddy_network + + email-service: + build: + context: ./email-service + dockerfile: Dockerfile + container_name: email_service + restart: always + environment: + - SMTP_ADDRESS=smtp.gmail.com + - SMTP_PORT=587 + - SMTP_USER_NAME=support@nabd-co.com + - SMTP_PASSWORD=zwziglbpxyfogafc + - ADMIN_EMAIL=support@nabd-co.com + - PORT=3001 + networks: + - caddy_network + +volumes: + traceability_data: + driver: local networks: caddy_network: diff --git a/email-service/Dockerfile b/email-service/Dockerfile new file mode 100644 index 0000000..f99b5ab --- /dev/null +++ b/email-service/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --production + +COPY . . + +EXPOSE 3001 + +CMD ["node", "index.js"] diff --git a/email-service/index.js b/email-service/index.js new file mode 100644 index 0000000..fd41642 --- /dev/null +++ b/email-service/index.js @@ -0,0 +1,259 @@ +const express = require('express'); +const nodemailer = require('nodemailer'); +const cors = require('cors'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// SMTP Configuration from environment variables +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_ADDRESS || 'smtp.gmail.com', + port: parseInt(process.env.SMTP_PORT || '587'), + secure: false, // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER_NAME, + pass: process.env.SMTP_PASSWORD, + }, +}); + +// Verify SMTP connection on startup +transporter.verify((error) => { + if (error) { + console.error('SMTP connection error:', error); + } else { + console.log('SMTP server is ready to send emails'); + } +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +// Send email endpoint +app.post('/send', async (req, res) => { + const { to, subject, html, text } = req.body; + + if (!to || !subject || (!html && !text)) { + return res.status(400).json({ error: 'Missing required fields: to, subject, and html or text' }); + } + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to, + subject, + text, + html, + }); + + console.log('Email sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send email:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +// Predefined email templates +app.post('/send/welcome', async (req, res) => { + const { to, username } = req.body; + + if (!to || !username) { + return res.status(400).json({ error: 'Missing required fields: to, username' }); + } + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to, + subject: 'Welcome to Traceability Dashboard - Registration Pending', + html: ` +
+

Welcome, ${username}!

+

Thank you for registering at the Traceability Dashboard.

+

Your registration is pending admin approval. You will receive another email once your account has been approved.

+
+

This is an automated message from NABD Solutions.

+
+ `, + }); + + console.log('Welcome email sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send welcome email:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +app.post('/send/admin-notification', async (req, res) => { + const { username, email } = req.body; + + if (!username || !email) { + return res.status(400).json({ error: 'Missing required fields: username, email' }); + } + + const adminEmail = process.env.ADMIN_EMAIL || 'support@nabd-co.com'; + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to: adminEmail, + subject: 'New User Registration - Pending Approval', + html: ` +
+

New User Registration

+

A new user has registered and is awaiting approval:

+
    +
  • Username: ${username}
  • +
  • Email: ${email}
  • +
+

Please log in to the admin dashboard to approve or reject this registration.

+
+

This is an automated message from NABD Solutions.

+
+ `, + }); + + console.log('Admin notification sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send admin notification:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +app.post('/send/approval', async (req, res) => { + const { to, username } = req.body; + + if (!to || !username) { + return res.status(400).json({ error: 'Missing required fields: to, username' }); + } + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to, + subject: 'Account Approved - Traceability Dashboard', + html: ` +
+

Account Approved!

+

Congratulations, ${username}!

+

Your account has been approved. You can now log in to the Traceability Dashboard.

+
+

This is an automated message from NABD Solutions.

+
+ `, + }); + + console.log('Approval email sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send approval email:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +app.post('/send/rejection', async (req, res) => { + const { to, username, reason } = req.body; + + if (!to || !username) { + return res.status(400).json({ error: 'Missing required fields: to, username' }); + } + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to, + subject: 'Registration Update - Traceability Dashboard', + html: ` +
+

Registration Update

+

Hello ${username},

+

Unfortunately, your registration request was not approved.

+ ${reason ? `

Reason: ${reason}

` : ''} +

If you believe this was a mistake, please contact the administrator.

+
+

This is an automated message from NABD Solutions.

+
+ `, + }); + + console.log('Rejection email sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send rejection email:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +app.post('/send/member-added', async (req, res) => { + const { to, username, addedBy } = req.body; + + if (!to || !username) { + return res.status(400).json({ error: 'Missing required fields: to, username' }); + } + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to, + subject: 'You have been added to Traceability Dashboard', + html: ` +
+

Welcome to Traceability Dashboard!

+

Hello ${username},

+

You have been added to the Traceability Dashboard${addedBy ? ` by ${addedBy}` : ''}.

+

You can now log in using your credentials.

+
+

This is an automated message from NABD Solutions.

+
+ `, + }); + + console.log('Member added email sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send member added email:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +app.post('/send/member-removed', async (req, res) => { + const { to, username } = req.body; + + if (!to || !username) { + return res.status(400).json({ error: 'Missing required fields: to, username' }); + } + + try { + const info = await transporter.sendMail({ + from: `"Traceability Dashboard" <${process.env.SMTP_USER_NAME}>`, + to, + subject: 'Account Update - Traceability Dashboard', + html: ` +
+

Account Update

+

Hello ${username},

+

Your access to the Traceability Dashboard has been removed.

+

If you believe this was a mistake, please contact the administrator.

+
+

This is an automated message from NABD Solutions.

+
+ `, + }); + + console.log('Member removed email sent:', info.messageId); + res.json({ success: true, messageId: info.messageId }); + } catch (error) { + console.error('Failed to send member removed email:', error); + res.status(500).json({ error: 'Failed to send email', details: error.message }); + } +}); + +const PORT = process.env.PORT || 3001; +app.listen(PORT, () => { + console.log(`Email service running on port ${PORT}`); +}); diff --git a/email-service/package.json b/email-service/package.json new file mode 100644 index 0000000..d76f5ca --- /dev/null +++ b/email-service/package.json @@ -0,0 +1,14 @@ +{ + "name": "email-service", + "version": "1.0.0", + "description": "SMTP email notification service for Traceability Dashboard", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "express": "^4.18.2", + "nodemailer": "^6.9.7", + "cors": "^2.8.5" + } +} diff --git a/src/App.tsx b/src/App.tsx index 0096cd7..ff645c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,12 @@ import TraceabilityMatrixPage from "./pages/TraceabilityMatrixPage"; import ESPIDFHelperPage from "./pages/ESPIDFHelperPage"; import WorkPackageGraphPage from "./pages/WorkPackageGraphPage"; import SelectedSensorsPage from "./pages/SelectedSensorsPage"; +import PlanningPage from "./pages/PlanningPage"; +import TaskDetailPage from "./pages/TaskDetailPage"; +import BudgetPage from "./pages/BudgetPage"; import LoginPage from "./pages/LoginPage"; +import RegisterPage from "./pages/RegisterPage"; +import AdminPage from "./pages/AdminPage"; import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); @@ -27,6 +32,7 @@ const App = () => ( } /> + } /> ( } /> + + + + } + /> ( } /> + + + + } + /> + + + + } + /> + + + + } + /> void; onClose?: () => void; @@ -38,7 +42,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp // Server endpoint config - persisted const [serverUrl, setServerUrl] = useState(() => - localStorage.getItem(SERVER_URL_KEY) || '/api/traceability' + localStorage.getItem(SERVER_URL_KEY) || (API_URL ? `${API_URL}/api/traceability` : '/api/traceability') ); // Drag and drop state @@ -91,13 +95,153 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp if (file) handleFile(file); }; + // Reload from static CSV file + const handleReloadFromCSV = async () => { + setIsLoading(true); + setLogs([]); + setErrors([]); + setParseResult(null); + + try { + const cacheBuster = `?t=${Date.now()}`; + addLog(`🔍 Fetching /data/traceability_export.csv${cacheBuster}`); + + const response = await fetch(`/data/traceability_export.csv${cacheBuster}`); + + if (!response.ok) { + throw new Error(`Failed to load CSV: ${response.status} ${response.statusText}`); + } + + const csvText = await response.text(); + addLog(`📄 Received ${csvText.length} bytes`); + + const result = parseCSVContent(csvText); + setParseResult(result); + setLogs(prev => [...prev, ...result.logs]); + setErrors(result.errors); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + setErrors([errorMsg]); + addLog(`❌ Error: ${errorMsg}`); + } finally { + setIsLoading(false); + } + }; + + // Trigger sync from OpenProject (runs Python script on server) + const handleSyncFromServer = async () => { + if (!serverUrl) { + setErrors(['Please enter a server endpoint URL']); + return; + } + + localStorage.setItem(SERVER_URL_KEY, serverUrl); + + setIsLoading(true); + setLogs([]); + setErrors([]); + setParseResult(null); + + try { + // First, trigger the sync (runs Python script) + const syncUrl = serverUrl.replace(/\/api\/traceability\/?$/, '/api/traceability/sync'); + addLog(`🔄 Triggering sync at: ${syncUrl}`); + + const syncResponse = await fetch(syncUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!syncResponse.ok) { + const errorData = await syncResponse.json().catch(() => ({})); + throw new Error(errorData.error || `Sync failed: ${syncResponse.status}`); + } + + const syncResult = await syncResponse.json(); + addLog(`✅ Sync complete: ${syncResult.message}`); + if (syncResult.stdout) { + syncResult.stdout.split('\n').filter(Boolean).forEach((line: string) => { + addLog(`📋 ${line}`); + }); + } + + // Now fetch the updated data + addLog(`🔍 Fetching updated data from: ${serverUrl}`); + + const response = await fetch(serverUrl, { + method: 'GET', + headers: { 'Accept': 'application/json, text/csv' } + }); + + if (!response.ok) { + throw new Error(`Server Error: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('content-type') || ''; + addLog(`📄 Response content-type: ${contentType}`); + + if (contentType.includes('text/csv')) { + addLog(`📋 Parsing CSV response...`); + const csvText = await response.text(); + const result = parseCSVContent(csvText); + + setLogs(prev => [...prev, ...result.logs]); + setErrors(result.errors); + setParseResult(result); + + } else if (contentType.includes('application/json')) { + addLog(`📋 Parsing JSON response...`); + const data = await response.json(); + + let workPackages: WorkPackage[]; + if (Array.isArray(data)) { + workPackages = data; + } else if (data.workPackages) { + workPackages = data.workPackages; + } else { + throw new Error('Invalid JSON format: expected array or {workPackages: [...]}'); + } + + addLog(`✅ Received ${workPackages.length} work packages`); + + const typeCounts = workPackages.reduce((acc, wp) => { + acc[wp.type] = (acc[wp.type] || 0) + 1; + return acc; + }, {} as Record); + + setParseResult({ + success: true, + workPackages, + logs: [], + errors: [], + typeCounts + }); + } else { + addLog(`⚠️ Unknown content-type, attempting CSV parse...`); + const text = await response.text(); + const result = parseCSVContent(text); + setLogs(prev => [...prev, ...result.logs]); + setErrors(result.errors); + setParseResult(result); + } + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + setErrors([errorMsg]); + addLog(`❌ Error: ${errorMsg}`); + } finally { + setIsLoading(false); + } + }; + + // Just fetch existing data (no sync) const handleFetchFromServer = async () => { if (!serverUrl) { setErrors(['Please enter a server endpoint URL']); return; } - // Save URL for next time localStorage.setItem(SERVER_URL_KEY, serverUrl); setIsLoading(true); @@ -159,7 +303,6 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp typeCounts }); } else { - // Try to parse as CSV anyway addLog(`⚠️ Unknown content-type, attempting CSV parse...`); const text = await response.text(); const result = parseCSVContent(text); @@ -179,7 +322,6 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp const handleApply = () => { if (parseResult?.success && parseResult.workPackages.length > 0) { - // Persist to localStorage for other users/sessions persistData(parseResult.workPackages); onDataLoaded(parseResult.workPackages); onClose?.(); @@ -204,7 +346,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp Update Traceability Data - Upload a CSV file or fetch from your server + Upload a CSV file, reload from static file, or sync from OpenProject @@ -212,15 +354,15 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp - Upload CSV + Upload / Reload - Server Fetch + Server Sync - {/* Tab 1: Manual CSV Upload */} + {/* Tab 1: Manual CSV Upload / Reload */}
+ +
+ +
+

- Run python get_traceability.py to generate the CSV file + Run python get_traceability.py to generate the CSV file, then reload.

- {/* Tab 2: Server Fetch */} + {/* Tab 2: Server Sync */}
- + setServerUrl(e.target.value)} />

- Endpoint that runs your Python script and returns CSV or JSON + Uses the data service API to sync with OpenProject

- + +
+ + +
+
-

Server Setup:

-
    -
  1. Create an endpoint that runs get_traceability.py
  2. -
  3. Return the CSV file or JSON with work packages
  4. -
  5. Example: GET /api/traceability → returns CSV
  6. -
+

How it works:

+
    +
  • Sync from OpenProject: Runs the Python script on the server to fetch latest data from OpenProject API
  • +
  • Fetch Existing Data: Gets the last synced CSV from the server (no OpenProject call)
  • +
+

+ Note: Server sync only works in deployed environment with the data-service running. +

@@ -337,7 +511,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp {/* Logs */} {logs.length > 0 && ( -
+
View logs ({logs.length} entries) diff --git a/src/components/budget/BudgetSettingsPanel.tsx b/src/components/budget/BudgetSettingsPanel.tsx new file mode 100644 index 0000000..c9d0dc7 --- /dev/null +++ b/src/components/budget/BudgetSettingsPanel.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { budgetService } from '@/services/budgetService'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/hooks/use-toast'; +import { Settings, RotateCcw } from 'lucide-react'; + +const CURRENCIES = [ + { code: 'USD', symbol: '$', name: 'US Dollar' }, + { code: 'EUR', symbol: '€', name: 'Euro' }, + { code: 'GBP', symbol: '£', name: 'British Pound' }, + { code: 'AED', symbol: 'د.إ', name: 'UAE Dirham' }, + { code: 'SAR', symbol: 'ر.س', name: 'Saudi Riyal' }, + { code: 'EGP', symbol: 'ج.م', name: 'Egyptian Pound' }, + { code: 'INR', symbol: '₹', name: 'Indian Rupee' }, + { code: 'JPY', symbol: '¥', name: 'Japanese Yen' }, + { code: 'CNY', symbol: '¥', name: 'Chinese Yuan' }, +]; + +const MONTHS = [ + { value: 1, label: 'January' }, + { value: 2, label: 'February' }, + { value: 3, label: 'March' }, + { value: 4, label: 'April' }, + { value: 5, label: 'May' }, + { value: 6, label: 'June' }, + { value: 7, label: 'July' }, + { value: 8, label: 'August' }, + { value: 9, label: 'September' }, + { value: 10, label: 'October' }, + { value: 11, label: 'November' }, + { value: 12, label: 'December' }, +]; + +interface BudgetSettingsPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BudgetSettingsPanel({ open, onOpenChange }: BudgetSettingsPanelProps) { + const { settings, updateSettings } = useBudgetContext(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [currency, setCurrency] = useState(settings?.currency || 'USD'); + const [fiscalYearStart, setFiscalYearStart] = useState(settings?.fiscalYearStart || 1); + const [lowRunwayWarning, setLowRunwayWarning] = useState(settings?.lowRunwayWarning?.toString() || '6'); + const [isResetting, setIsResetting] = useState(false); + + const handleSave = () => { + updateSettings({ + currency, + fiscalYearStart, + lowRunwayWarning: parseInt(lowRunwayWarning) || 6, + }); + onOpenChange(false); + }; + + const handleReset = async () => { + if (!confirm('This will reset all budget data to sample data. Are you sure?')) return; + + setIsResetting(true); + try { + await budgetService.resetToSampleData(); + queryClient.invalidateQueries({ queryKey: ['budget'] }); + toast({ title: 'Budget data reset to sample data' }); + onOpenChange(false); + } catch (error) { + toast({ + title: 'Failed to reset data', + description: error instanceof Error ? error.message : 'Unknown error', + variant: 'destructive' + }); + } finally { + setIsResetting(false); + } + }; + + return ( + + + + + + Budget Settings + + + +
+ {/* Currency */} +
+ + +
+ + {/* Fiscal Year Start */} +
+ + +

+ The first month of your fiscal year for reporting purposes +

+
+ + {/* Low Runway Warning */} +
+ + setLowRunwayWarning(e.target.value)} + /> +

+ Show warning when runway drops below this many months +

+
+ + {/* Reset Data */} +
+
+
+

Reset to Sample Data

+

+ Restore all budget data to initial sample values +

+
+ +
+
+
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/budget/BudgetSummaryCards.tsx b/src/components/budget/BudgetSummaryCards.tsx new file mode 100644 index 0000000..62bf9aa --- /dev/null +++ b/src/components/budget/BudgetSummaryCards.tsx @@ -0,0 +1,158 @@ +import { useBudgetSummary } from '@/hooks/useBudget'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Wallet, + TrendingDown, + Clock, + ArrowUpRight, + ArrowDownRight, + AlertTriangle, + XCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export function BudgetSummaryCards() { + const { data: summary, isLoading } = useBudgetSummary(); + const { formatCurrency, settings } = useBudgetContext(); + + if (isLoading || !summary) { + return ( +
+ {[1, 2, 3, 4].map(i => ( + + +
+
+ + +
+
+ + + ))} +
+ ); + } + + const lowRunway = settings?.lowRunwayWarning || 6; + const isRunwayLow = summary.estimatedRunway !== Infinity && summary.estimatedRunway <= lowRunway; + + // Count budget alerts + const overBudgetCount = summary.categoryAlerts?.filter(a => a.isOverBudget).length || 0; + const warningCount = summary.categoryAlerts?.filter(a => a.isWarning).length || 0; + const totalAlerts = overBudgetCount + warningCount; + + return ( +
+ {/* Total Balance */} + + + + Total Balance + + + + +
= 0 ? "text-green-600 dark:text-green-400" : "text-destructive" + )}> + {formatCurrency(summary.totalBalance)} +
+
+ + + {formatCurrency(summary.totalIncome)} in + + + + {formatCurrency(summary.totalExpenses)} out + +
+
+
+ + {/* Monthly Burn Rate */} + + + + Monthly Burn Rate + + + + +
+ {formatCurrency(summary.monthlyBurnRate)} +
+

+ Average over last 6 months +

+
+
+ + {/* Estimated Runway */} + + + + Estimated Runway + + {isRunwayLow ? ( + + ) : ( + + )} + + +
+ {summary.estimatedRunway === Infinity + ? '∞' + : `${summary.estimatedRunway} months` + } +
+

+ {isRunwayLow + ? 'Warning: Low runway!' + : 'At current burn rate' + } +

+
+
+ + {/* Budget Alerts */} + 0 && "border-amber-500/50 bg-amber-500/5")}> + + + Budget Alerts + + {overBudgetCount > 0 ? ( + + ) : warningCount > 0 ? ( + + ) : ( + + )} + + +
0 ? "text-destructive" : warningCount > 0 ? "text-amber-500" : "text-green-600 dark:text-green-400" + )}> + {totalAlerts === 0 ? 'All Good' : `${totalAlerts} Alert${totalAlerts > 1 ? 's' : ''}`} +
+

+ {overBudgetCount > 0 + ? `${overBudgetCount} over budget` + : warningCount > 0 + ? `${warningCount} approaching limit` + : 'No budget concerns' + } +

+
+
+
+ ); +} diff --git a/src/components/budget/BudgetTargetsManager.tsx b/src/components/budget/BudgetTargetsManager.tsx new file mode 100644 index 0000000..f6cc1a5 --- /dev/null +++ b/src/components/budget/BudgetTargetsManager.tsx @@ -0,0 +1,417 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { budgetService } from '@/services/budgetService'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { BudgetTarget, EXPENSE_CATEGORIES, ExpenseCategory } from '@/types/budget'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useToast } from '@/hooks/use-toast'; +import { + Plus, + Trash2, + Edit, + Target, + AlertTriangle, + CheckCircle, + XCircle, +} from 'lucide-react'; +import { CATEGORY_ICONS } from './ExpenseBreakdownChart'; +import { useBudgetSummary } from '@/hooks/useBudget'; + +export function BudgetTargetsManager() { + const [dialogOpen, setDialogOpen] = useState(false); + const [editingTarget, setEditingTarget] = useState(null); + const { formatCurrency } = useBudgetContext(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const { data: targets = [], isLoading } = useQuery({ + queryKey: ['budget', 'targets'], + queryFn: budgetService.getTargets, + }); + + const { data: summary } = useBudgetSummary(); + + const createMutation = useMutation({ + mutationFn: budgetService.createTarget, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget', 'targets'] }); + queryClient.invalidateQueries({ queryKey: ['budget', 'summary'] }); + toast({ title: 'Budget target created' }); + setDialogOpen(false); + }, + onError: (error: Error) => { + toast({ title: 'Failed to create target', description: error.message, variant: 'destructive' }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, updates }: { id: string; updates: Partial }) => + budgetService.updateTarget(id, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget', 'targets'] }); + queryClient.invalidateQueries({ queryKey: ['budget', 'summary'] }); + toast({ title: 'Budget target updated' }); + setDialogOpen(false); + setEditingTarget(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: budgetService.deleteTarget, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['budget', 'targets'] }); + queryClient.invalidateQueries({ queryKey: ['budget', 'summary'] }); + toast({ title: 'Budget target removed' }); + }, + }); + + const handleEdit = (target: BudgetTarget) => { + setEditingTarget(target); + setDialogOpen(true); + }; + + const handleDelete = (id: string) => { + if (confirm('Are you sure you want to delete this budget target?')) { + deleteMutation.mutate(id); + } + }; + + // Get categories that don't have targets yet + const usedCategories = targets.map(t => t.category); + const availableCategories = EXPENSE_CATEGORIES.filter(c => !usedCategories.includes(c)); + + // Category alerts from summary + const categoryAlerts = summary?.categoryAlerts || []; + + if (isLoading) { + return ( + + + + + Budget Targets + + + Loading... + + ); + } + + return ( + + + + + Budget Targets + + + + + {/* Alerts Section */} + {categoryAlerts.some(a => a.isOverBudget || a.isWarning) && ( +
+

Current Month Alerts

+ {categoryAlerts + .filter(a => a.isOverBudget || a.isWarning) + .map((alert) => ( +
+
+ {alert.isOverBudget ? ( + + ) : ( + + )} + + {CATEGORY_ICONS[alert.category]} + + {alert.category} +
+
+

+ {formatCurrency(alert.spent)} / {formatCurrency(alert.limit)} +

+

+ {alert.percentUsed.toFixed(0)}% used +

+
+
+ ))} +
+ )} + + {targets.length === 0 ? ( +
+ +

No budget targets set yet.

+

Add targets to monitor category spending limits.

+
+ ) : ( + + + + Category + Monthly Limit + Alert At + This Month + Actions + + + + {targets.map((target) => { + const alert = categoryAlerts.find(a => a.category === target.category); + const percentUsed = alert?.percentUsed || 0; + const spent = alert?.spent || 0; + + return ( + + +
+ + {CATEGORY_ICONS[target.category]} + + {target.category} +
+
+ + {formatCurrency(target.monthlyLimit)} + + + {target.alertThreshold}% + + +
+
+ {formatCurrency(spent)} + + {percentUsed >= 100 ? ( + + ) : percentUsed >= target.alertThreshold ? ( + + ) : ( + + )} + {percentUsed.toFixed(0)}% + +
+ = 100 + ? '[&>div]:bg-destructive' + : percentUsed >= target.alertThreshold + ? '[&>div]:bg-yellow-500' + : '[&>div]:bg-green-500' + }`} + /> +
+
+ +
+ + +
+
+
+ ); + })} +
+
+ )} + + { + if (editingTarget) { + updateMutation.mutate({ id: editingTarget.id, updates: data }); + } else { + createMutation.mutate(data as any); + } + }} + /> +
+
+ ); +} + +interface TargetDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + target?: BudgetTarget | null; + availableCategories: ExpenseCategory[]; + onSave: (data: Partial) => void; +} + +function TargetDialog({ + open, + onOpenChange, + target, + availableCategories, + onSave +}: TargetDialogProps) { + const [category, setCategory] = useState(target?.category || availableCategories[0]); + const [monthlyLimit, setMonthlyLimit] = useState(target?.monthlyLimit?.toString() || ''); + const [alertThreshold, setAlertThreshold] = useState(target?.alertThreshold?.toString() || '80'); + + // Reset form when dialog opens + useState(() => { + if (open) { + setCategory(target?.category || availableCategories[0]); + setMonthlyLimit(target?.monthlyLimit?.toString() || ''); + setAlertThreshold(target?.alertThreshold?.toString() || '80'); + } + }); + + const handleSave = () => { + if (!monthlyLimit || parseFloat(monthlyLimit) <= 0) return; + + onSave({ + category, + monthlyLimit: parseFloat(monthlyLimit), + alertThreshold: parseInt(alertThreshold) || 80, + }); + }; + + return ( + + + + + {target ? 'Edit Budget Target' : 'Add Budget Target'} + + + +
+
+ + +
+ +
+ +
+ + $ + + setMonthlyLimit(e.target.value)} + placeholder="0.00" + className="pl-7" + /> +
+
+ +
+ + setAlertThreshold(e.target.value)} + placeholder="80" + /> +

+ Show warning when spending reaches this percentage of the limit +

+
+
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/budget/EquityManager.tsx b/src/components/budget/EquityManager.tsx new file mode 100644 index 0000000..2aa85e4 --- /dev/null +++ b/src/components/budget/EquityManager.tsx @@ -0,0 +1,418 @@ +import { useState } from 'react'; +import { + useFounders, + useCreateFounder, + useUpdateFounder, + useDeleteFounder +} from '@/hooks/useBudget'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { Founder } from '@/types/budget'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend +} from 'recharts'; +import { + Plus, + MoreVertical, + Edit, + Trash2, + Users, + TrendingUp, +} from 'lucide-react'; + +const COLORS = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', + 'hsl(200 70% 50%)', + 'hsl(280 70% 50%)', + 'hsl(160 70% 40%)', +]; + +interface FounderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + founder?: Founder | null; + onSave: (data: Omit) => void; +} + +function FounderDialog({ open, onOpenChange, founder, onSave }: FounderDialogProps) { + const [name, setName] = useState(founder?.name ?? ''); + const [initialContribution, setInitialContribution] = useState( + founder?.initialContribution?.toString() ?? '' + ); + const [additionalFunding, setAdditionalFunding] = useState( + founder?.additionalFunding?.toString() ?? '' + ); + const [sharePercentage, setSharePercentage] = useState( + founder?.sharePercentage?.toString() ?? '' + ); + + const handleSave = () => { + if (!name.trim() || !sharePercentage) return; + + onSave({ + name: name.trim(), + initialContribution: parseFloat(initialContribution) || 0, + additionalFunding: parseFloat(additionalFunding) || 0, + sharePercentage: parseFloat(sharePercentage) || 0, + }); + onOpenChange(false); + }; + + return ( + + + + + {founder ? 'Edit Founder' : 'Add Founder'} + + + +
+
+ + setName(e.target.value)} + placeholder="Founder name" + /> +
+ +
+ + setInitialContribution(e.target.value)} + placeholder="0" + /> +
+ +
+ + setAdditionalFunding(e.target.value)} + placeholder="0" + /> +
+ +
+ + setSharePercentage(e.target.value)} + placeholder="0" + /> +
+
+ + + + + +
+
+ ); +} + +export function EquityManager() { + const { data: founders = [], isLoading } = useFounders(); + const createFounder = useCreateFounder(); + const updateFounder = useUpdateFounder(); + const deleteFounder = useDeleteFounder(); + const { formatCurrency } = useBudgetContext(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedFounder, setSelectedFounder] = useState(null); + const [deleteId, setDeleteId] = useState(null); + + const handleCreate = () => { + setSelectedFounder(null); + setDialogOpen(true); + }; + + const handleEdit = (founder: Founder) => { + setSelectedFounder(founder); + setDialogOpen(true); + }; + + const handleSave = (data: Omit) => { + if (selectedFounder) { + updateFounder.mutate({ id: selectedFounder.id, updates: data }); + } else { + createFounder.mutate(data); + } + }; + + const handleDelete = () => { + if (deleteId) { + deleteFounder.mutate(deleteId); + setDeleteId(null); + } + }; + + const pieData = founders.map(f => ({ + name: f.name, + value: f.sharePercentage, + })); + + const totalShares = founders.reduce((sum, f) => sum + f.sharePercentage, 0); + const totalContributions = founders.reduce((sum, f) => sum + f.totalContributed, 0); + + return ( +
+ {/* Summary Cards */} +
+ + + + Total Founders + + + + +
{founders.length}
+

+ {totalShares.toFixed(1)}% allocated +

+
+
+ + + + + Total Contributions + + + + +
+ {formatCurrency(totalContributions)} +
+

+ From all founders +

+
+
+
+ +
+ {/* Equity Distribution Chart */} + + + Equity Distribution + + + {pieData.length === 0 ? ( +
+ No founders added yet. +
+ ) : ( +
+ + + `${name}: ${value}%`} + labelLine={false} + > + {pieData.map((_, index) => ( + + ))} + + `${value}%`} + contentStyle={{ + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '8px', + color: 'hsl(var(--popover-foreground))' + }} + /> + + + +
+ )} +
+
+ + {/* Founders Table */} + + +
+ Founders + +
+
+ + {isLoading ? ( +
Loading...
+ ) : founders.length === 0 ? ( +
+ +

No founders added yet.

+ +
+ ) : ( +
+ + + + Name + Contributed + Share % + + + + + {founders.map((founder, index) => ( + + +
+
+ {founder.name} +
+ + + {formatCurrency(founder.totalContributed)} + + + {founder.sharePercentage}% + + + + + + + + handleEdit(founder)}> + + Edit + + setDeleteId(founder.id)} + className="text-destructive" + > + + Remove + + + + + + ))} + +
+
+ )} +
+
+
+ + {/* Founder Dialog */} + + + {/* Delete Confirmation */} + setDeleteId(null)}> + + + Remove Founder? + + This action cannot be undone. This will remove the founder from the equity table. + + + + Cancel + Remove + + + +
+ ); +} diff --git a/src/components/budget/ExpenseBreakdownChart.tsx b/src/components/budget/ExpenseBreakdownChart.tsx new file mode 100644 index 0000000..029ae95 --- /dev/null +++ b/src/components/budget/ExpenseBreakdownChart.tsx @@ -0,0 +1,228 @@ +import { useBudgetSummary, useMonthlyData } from '@/hooks/useBudget'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from 'recharts'; +import { + Server, + Users, + ShoppingCart, + Building, + Zap, + Cpu, + Package, + Plane, + Scale, + Shield, + Briefcase, + HelpCircle, + Megaphone, + FileText, +} from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +const CATEGORY_COLORS: Record = { + 'Purchase': 'hsl(340 82% 52%)', // Vibrant pink + 'Salary': 'hsl(262 83% 58%)', // Rich purple + 'Cloud/Subscription': 'hsl(199 89% 48%)', // Bright cyan + 'Marketing': 'hsl(43 96% 56%)', // Golden yellow + 'Rent': 'hsl(280 68% 50%)', // Deep violet + 'Utilities': 'hsl(173 80% 40%)', // Teal + 'Hardware': 'hsl(22 92% 53%)', // Vivid orange + 'Software': 'hsl(142 76% 36%)', // Emerald green + 'Travel': 'hsl(217 91% 60%)', // Bright blue + 'Legal': 'hsl(350 89% 60%)', // Coral red + 'Insurance': 'hsl(192 91% 36%)', // Deep cyan + 'Office Supplies': 'hsl(83 78% 41%)', // Lime green + 'Consulting': 'hsl(291 64% 42%)', // Magenta + 'Other': 'hsl(220 14% 46%)', // Slate +}; + +const CATEGORY_ICONS: Record = { + 'Purchase': , + 'Salary': , + 'Cloud/Subscription': , + 'Marketing': , + 'Rent': , + 'Utilities': , + 'Hardware': , + 'Software': , + 'Travel': , + 'Legal': , + 'Insurance': , + 'Office Supplies': , + 'Consulting': , + 'Other': , +}; + +export function ExpenseBreakdownChart() { + const { data: summary, isLoading: summaryLoading } = useBudgetSummary(); + const { data: monthlyData, isLoading: monthlyLoading } = useMonthlyData(6); + const { formatCurrency } = useBudgetContext(); + + const pieData = summary?.expensesByCategory + ? Object.entries(summary.expensesByCategory) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value) + : []; + + const barData = monthlyData?.map(d => ({ + ...d, + month: new Date(d.month + '-01').toLocaleDateString('en-US', { month: 'short' }), + })) || []; + + if (summaryLoading || monthlyLoading) { + return ( + + + Financial Overview + + + Loading charts... + + + ); + } + + return ( + + + Financial Overview + + + + + Expense Breakdown + Monthly Trends + + + + {pieData.length === 0 ? ( +
+ No expense data yet. Add transactions to see breakdown. +
+ ) : ( +
+ {/* Pie Chart */} +
+ + + + {pieData.map((entry, index) => ( + + ))} + + formatCurrency(value)} + contentStyle={{ + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '8px', + color: 'hsl(var(--popover-foreground))' + }} + /> + + +
+ + {/* Category List */} +
+ {pieData.map((entry) => ( +
+
+
+ + {CATEGORY_ICONS[entry.name] || CATEGORY_ICONS['Other']} + + {entry.name} +
+ + {formatCurrency(entry.value)} + +
+ ))} +
+
+ )} + + + + {barData.length === 0 ? ( +
+ No monthly data yet. +
+ ) : ( +
+ + + + + `$${(value / 1000).toFixed(0)}k`} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '8px', + color: 'hsl(var(--popover-foreground))' + }} + /> + + + + + +
+ )} +
+ + + + ); +} + +export { CATEGORY_COLORS, CATEGORY_ICONS }; diff --git a/src/components/budget/FinancialProjections.tsx b/src/components/budget/FinancialProjections.tsx new file mode 100644 index 0000000..5aaeeea --- /dev/null +++ b/src/components/budget/FinancialProjections.tsx @@ -0,0 +1,257 @@ +import { useBudgetSummary, useRecurringTransactions } from '@/hooks/useBudget'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Slider } from '@/components/ui/slider'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { useState, useMemo } from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { TrendingDown, TrendingUp, AlertTriangle, Sparkles } from 'lucide-react'; + +interface ProjectionScenario { + name: string; + burnMultiplier: number; + color: string; + description: string; +} + +const SCENARIOS: ProjectionScenario[] = [ + { name: 'Optimistic', burnMultiplier: 0.8, color: 'hsl(142 76% 36%)', description: '20% cost reduction' }, + { name: 'Current', burnMultiplier: 1.0, color: 'hsl(221 83% 53%)', description: 'No changes' }, + { name: 'Pessimistic', burnMultiplier: 1.3, color: 'hsl(0 84% 60%)', description: '30% cost increase' }, +]; + +export function FinancialProjections() { + const { data: summary, isLoading: summaryLoading } = useBudgetSummary(); + const { data: recurring = [] } = useRecurringTransactions(); + const { formatCurrency, settings } = useBudgetContext(); + + const [monthsToProject, setMonthsToProject] = useState(12); + const [additionalMonthlyIncome, setAdditionalMonthlyIncome] = useState(0); + + const monthlyRecurringExpense = useMemo(() => { + return recurring + .filter(r => r.isActive && r.type === 'Expense' && r.frequency === 'monthly') + .reduce((sum, r) => sum + r.amount, 0); + }, [recurring]); + + const monthlyRecurringIncome = useMemo(() => { + return recurring + .filter(r => r.isActive && r.type === 'Income' && r.frequency === 'monthly') + .reduce((sum, r) => sum + r.amount, 0); + }, [recurring]); + + const projectionData = useMemo(() => { + if (!summary) return []; + + const data: Record[] = []; + const startBalance = summary.totalBalance; + const baseBurn = summary.monthlyBurnRate; + const baseIncome = monthlyRecurringIncome + additionalMonthlyIncome; + + for (let i = 0; i <= monthsToProject; i++) { + const month = new Date(); + month.setMonth(month.getMonth() + i); + const monthLabel = month.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + + const point: Record = { month: monthLabel }; + + SCENARIOS.forEach((scenario) => { + const monthlyNet = baseIncome - (baseBurn * scenario.burnMultiplier); + point[scenario.name] = Math.max(0, startBalance + (monthlyNet * i)); + }); + + data.push(point); + } + + return data; + }, [summary, monthsToProject, additionalMonthlyIncome, monthlyRecurringIncome]); + + const runwayByScenario = useMemo(() => { + if (!summary || summary.monthlyBurnRate === 0) return {}; + + const baseIncome = monthlyRecurringIncome + additionalMonthlyIncome; + const result: Record = {}; + + SCENARIOS.forEach((scenario) => { + const monthlyNet = baseIncome - (summary.monthlyBurnRate * scenario.burnMultiplier); + if (monthlyNet >= 0) { + result[scenario.name] = Infinity; + } else { + result[scenario.name] = Math.floor(summary.totalBalance / Math.abs(monthlyNet)); + } + }); + + return result; + }, [summary, additionalMonthlyIncome, monthlyRecurringIncome]); + + if (summaryLoading || !summary) { + return ( + + Loading projections... + + ); + } + + const lowRunwayThreshold = settings?.lowRunwayWarning || 6; + + return ( +
+ {/* Scenario Summary Cards */} +
+ {SCENARIOS.map((scenario) => { + const runway = runwayByScenario[scenario.name] || 0; + const isLow = runway !== Infinity && runway <= lowRunwayThreshold; + const isCritical = runway !== Infinity && runway <= 3; + + return ( + +
+ + + {scenario.name} Scenario + {scenario.name === 'Optimistic' && } + {scenario.name === 'Current' && } + {scenario.name === 'Pessimistic' && } + + + +
+ {runway === Infinity ? '∞' : `${runway} months`} +
+

{scenario.description}

+ {isLow && ( + + + {isCritical ? 'Critical' : 'Low Runway'} + + )} +
+ + ); + })} +
+ + {/* Projection Controls */} + + + Projection Parameters + + +
+
+
+ + {monthsToProject} months +
+ setMonthsToProject(v)} + min={6} + max={36} + step={1} + /> +
+ +
+
+ + {formatCurrency(additionalMonthlyIncome)} +
+ setAdditionalMonthlyIncome(v)} + min={0} + max={100000} + step={1000} + /> +
+
+ +
+
+
Current Balance
+
{formatCurrency(summary.totalBalance)}
+
+
+
Monthly Burn
+
{formatCurrency(summary.monthlyBurnRate)}
+
+
+
Recurring Income
+
{formatCurrency(monthlyRecurringIncome)}
+
+
+
Recurring Expenses
+
{formatCurrency(monthlyRecurringExpense)}
+
+
+
+
+ + {/* Projection Chart */} + + + Balance Projection + + +
+ + + + {SCENARIOS.map((scenario) => ( + + + + + ))} + + + + `$${(value / 1000).toFixed(0)}k`} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '8px', + color: 'hsl(var(--popover-foreground))' + }} + /> + + {SCENARIOS.map((scenario) => ( + + ))} + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/budget/RecurringTransactionManager.tsx b/src/components/budget/RecurringTransactionManager.tsx new file mode 100644 index 0000000..2b53671 --- /dev/null +++ b/src/components/budget/RecurringTransactionManager.tsx @@ -0,0 +1,335 @@ +import { useState } from 'react'; +import { + useRecurringTransactions, + useCreateRecurringTransaction, + useUpdateRecurringTransaction, + useDeleteRecurringTransaction, + useProcessRecurringTransactions, +} from '@/hooks/useBudget'; +import { useBudgetContext } from '@/contexts/BudgetContext'; +import { RecurringTransaction, EXPENSE_CATEGORIES, INCOME_CATEGORIES, RecurringFrequency } from '@/types/budget'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Plus, Pencil, Trash2, RefreshCw, Calendar, Repeat } from 'lucide-react'; +import { format } from 'date-fns'; + +export function RecurringTransactionManager() { + const { data: recurring = [], isLoading } = useRecurringTransactions(); + const createMutation = useCreateRecurringTransaction(); + const updateMutation = useUpdateRecurringTransaction(); + const deleteMutation = useDeleteRecurringTransaction(); + const processMutation = useProcessRecurringTransactions(); + const { formatCurrency } = useBudgetContext(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [formData, setFormData] = useState({ + name: '', + description: '', + category: 'Cloud/Subscription', + amount: '', + type: 'Expense' as 'Income' | 'Expense', + frequency: 'monthly' as RecurringFrequency, + nextRunDate: new Date().toISOString().substring(0, 10), + isActive: true, + }); + + const resetForm = () => { + setFormData({ + name: '', + description: '', + category: 'Cloud/Subscription', + amount: '', + type: 'Expense', + frequency: 'monthly', + nextRunDate: new Date().toISOString().substring(0, 10), + isActive: true, + }); + setEditing(null); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const payload = { + name: formData.name, + description: formData.description, + category: formData.category as any, + amount: parseFloat(formData.amount), + type: formData.type, + frequency: formData.frequency, + nextRunDate: formData.nextRunDate, + isActive: formData.isActive, + }; + + if (editing) { + updateMutation.mutate( + { id: editing.id, updates: payload }, + { onSuccess: () => { setDialogOpen(false); resetForm(); } } + ); + } else { + createMutation.mutate(payload, { + onSuccess: () => { setDialogOpen(false); resetForm(); } + }); + } + }; + + const handleEdit = (item: RecurringTransaction) => { + setEditing(item); + setFormData({ + name: item.name, + description: item.description, + category: item.category, + amount: item.amount.toString(), + type: item.type, + frequency: item.frequency, + nextRunDate: item.nextRunDate, + isActive: item.isActive, + }); + setDialogOpen(true); + }; + + const handleToggleActive = (item: RecurringTransaction) => { + updateMutation.mutate({ id: item.id, updates: { isActive: !item.isActive } }); + }; + + const categories = formData.type === 'Expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES; + + if (isLoading) { + return Loading...; + } + + return ( + + + + + Recurring Transactions + +
+ + { setDialogOpen(open); if (!open) resetForm(); }}> + + + + + + {editing ? 'Edit' : 'Add'} Recurring Transaction + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Monthly AWS Bill" + required + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + setFormData({ ...formData, amount: e.target.value })} + placeholder="0.00" + required + /> +
+ +
+ + +
+
+ +
+ + setFormData({ ...formData, nextRunDate: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="Optional description" + /> +
+ +
+ + setFormData({ ...formData, isActive: checked })} + /> +
+ + +
+
+
+
+
+ + {recurring.length === 0 ? ( +
+ No recurring transactions set up yet. +
+ ) : ( + + + + Name + Amount + Frequency + Next Run + Status + Actions + + + + {recurring.map((item) => ( + + +
+
{item.name}
+
{item.category}
+
+
+ + {item.type === 'Income' ? '+' : '-'}{formatCurrency(item.amount)} + + {item.frequency} + +
+ + {format(new Date(item.nextRunDate), 'MMM d, yyyy')} +
+
+ + + {item.isActive ? 'Active' : 'Paused'} + + + +
+ + + +
+
+
+ ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/budget/TransactionAttachmentUpload.tsx b/src/components/budget/TransactionAttachmentUpload.tsx new file mode 100644 index 0000000..debaed9 --- /dev/null +++ b/src/components/budget/TransactionAttachmentUpload.tsx @@ -0,0 +1,207 @@ +import { useState, useRef } from 'react'; +import { TransactionAttachment } from '@/types/budget'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; +import { + Upload, + X, + FileText, + Image as ImageIcon, + File, + Download, +} from 'lucide-react'; + +interface TransactionAttachmentUploadProps { + attachments: TransactionAttachment[]; + onAttachmentsChange: (attachments: TransactionAttachment[]) => void; + maxSize?: number; // in MB +} + +export function TransactionAttachmentUpload({ + attachments = [], + onAttachmentsChange, + maxSize = 5, +}: TransactionAttachmentUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + const { toast } = useToast(); + + const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const handleFiles = async (files: FileList | null) => { + if (!files) return; + + const newAttachments: TransactionAttachment[] = []; + + for (const file of Array.from(files)) { + // Check file size + if (file.size > maxSize * 1024 * 1024) { + toast({ + title: 'File too large', + description: `${file.name} exceeds ${maxSize}MB limit`, + variant: 'destructive', + }); + continue; + } + + // Read file as data URL + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + newAttachments.push({ + id: generateId(), + name: file.name, + type: file.type, + size: file.size, + dataUrl, + uploadedAt: new Date().toISOString(), + }); + } catch (error) { + toast({ + title: 'Failed to read file', + description: `Could not process ${file.name}`, + variant: 'destructive', + }); + } + } + + if (newAttachments.length > 0) { + onAttachmentsChange([...attachments, ...newAttachments]); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + handleFiles(e.dataTransfer.files); + }; + + const handleRemove = (id: string) => { + onAttachmentsChange(attachments.filter(a => a.id !== id)); + }; + + const handleDownload = (attachment: TransactionAttachment) => { + const link = document.createElement('a'); + link.href = attachment.dataUrl; + link.download = attachment.name; + link.click(); + }; + + const getFileIcon = (type: string) => { + if (type.startsWith('image/')) return ; + if (type.includes('pdf')) return ; + return ; + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+ {/* Drop Zone */} +
fileInputRef.current?.click()} + > + handleFiles(e.target.files)} + /> + +

+ Drop files here or click to upload +

+

+ Images, PDFs, Documents (max {maxSize}MB each) +

+
+ + {/* Attachments List */} + {attachments.length > 0 && ( +
+ {attachments.map((attachment) => ( +
+
+ {attachment.type.startsWith('image/') ? ( + {attachment.name} + ) : ( +
+ {getFileIcon(attachment.type)} +
+ )} +
+

{attachment.name}

+

+ {formatFileSize(attachment.size)} +

+
+
+
+ + +
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/budget/TransactionDialog.tsx b/src/components/budget/TransactionDialog.tsx new file mode 100644 index 0000000..674ae1e --- /dev/null +++ b/src/components/budget/TransactionDialog.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from 'react'; +import { + Transaction, + TransactionType, + TransactionAttachment, + EXPENSE_CATEGORIES, + INCOME_CATEGORIES, + ExpenseCategory, + IncomeCategory, +} from '@/types/budget'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { CATEGORY_ICONS } from './ExpenseBreakdownChart'; +import { TransactionAttachmentUpload } from './TransactionAttachmentUpload'; +import { ChevronDown, Paperclip } from 'lucide-react'; + +interface TransactionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + transaction?: Transaction | null; + onSave: (data: Omit) => void; +} + +export function TransactionDialog({ + open, + onOpenChange, + transaction, + onSave +}: TransactionDialogProps) { + const [type, setType] = useState(transaction?.type ?? 'Expense'); + const [date, setDate] = useState(transaction?.date ?? new Date().toISOString().split('T')[0]); + const [description, setDescription] = useState(transaction?.description ?? ''); + const [category, setCategory] = useState(transaction?.category ?? 'Other'); + const [amount, setAmount] = useState(transaction?.amount?.toString() ?? ''); + const [attachments, setAttachments] = useState(transaction?.attachments ?? []); + const [showAttachments, setShowAttachments] = useState(false); + + // Reset form when dialog opens/closes or transaction changes + useEffect(() => { + if (open) { + setType(transaction?.type ?? 'Expense'); + setDate(transaction?.date ?? new Date().toISOString().split('T')[0]); + setDescription(transaction?.description ?? ''); + setCategory(transaction?.category ?? 'Other'); + setAmount(transaction?.amount?.toString() ?? ''); + setAttachments(transaction?.attachments ?? []); + setShowAttachments((transaction?.attachments?.length ?? 0) > 0); + } + }, [open, transaction]); + + const categories = type === 'Expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES; + + const handleTypeChange = (newType: TransactionType) => { + setType(newType); + // Reset category when type changes + setCategory(newType === 'Expense' ? 'Other' : 'Revenue'); + }; + + const handleSave = () => { + if (!date || !description.trim() || !amount || parseFloat(amount) <= 0) return; + + onSave({ + type, + date, + description: description.trim(), + category: category as ExpenseCategory | IncomeCategory, + amount: parseFloat(amount), + attachments: attachments.length > 0 ? attachments : undefined, + }); + onOpenChange(false); + }; + + return ( + + + + + {transaction ? 'Edit Transaction' : 'Add Transaction'} + + + +
+ {/* Transaction Type */} +
+ + handleTypeChange(v as TransactionType)} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ + {/* Date */} +
+ + setDate(e.target.value)} + /> +
+ + {/* Description */} +
+ +