new features
This commit is contained in:
122
DATA-BACKUP-GUIDE.md
Normal file
122
DATA-BACKUP-GUIDE.md
Normal file
@@ -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 --
|
||||
```
|
||||
27
data-service/Dockerfile
Normal file
27
data-service/Dockerfile
Normal file
@@ -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"]
|
||||
744
data-service/index.js
Normal file
744
data-service/index.js
Normal file
@@ -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);
|
||||
});
|
||||
14
data-service/package.json
Normal file
14
data-service/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
213
deploy.sh
213
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 <container_name>"
|
||||
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 <backup_file>${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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
12
email-service/Dockerfile
Normal file
12
email-service/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
259
email-service/index.js
Normal file
259
email-service/index.js
Normal file
@@ -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: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #333;">Welcome, ${username}!</h1>
|
||||
<p>Thank you for registering at the Traceability Dashboard.</p>
|
||||
<p>Your registration is pending admin approval. You will receive another email once your account has been approved.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from NABD Solutions.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #333;">New User Registration</h1>
|
||||
<p>A new user has registered and is awaiting approval:</p>
|
||||
<ul>
|
||||
<li><strong>Username:</strong> ${username}</li>
|
||||
<li><strong>Email:</strong> ${email}</li>
|
||||
</ul>
|
||||
<p>Please log in to the admin dashboard to approve or reject this registration.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from NABD Solutions.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #333;">Account Approved!</h1>
|
||||
<p>Congratulations, ${username}!</p>
|
||||
<p>Your account has been approved. You can now log in to the Traceability Dashboard.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from NABD Solutions.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #333;">Registration Update</h1>
|
||||
<p>Hello ${username},</p>
|
||||
<p>Unfortunately, your registration request was not approved.</p>
|
||||
${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ''}
|
||||
<p>If you believe this was a mistake, please contact the administrator.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from NABD Solutions.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #333;">Welcome to Traceability Dashboard!</h1>
|
||||
<p>Hello ${username},</p>
|
||||
<p>You have been added to the Traceability Dashboard${addedBy ? ` by ${addedBy}` : ''}.</p>
|
||||
<p>You can now log in using your credentials.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from NABD Solutions.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #333;">Account Update</h1>
|
||||
<p>Hello ${username},</p>
|
||||
<p>Your access to the Traceability Dashboard has been removed.</p>
|
||||
<p>If you believe this was a mistake, please contact the administrator.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from NABD Solutions.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
14
email-service/package.json
Normal file
14
email-service/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
38
src/App.tsx
38
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 = () => (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -35,6 +41,14 @@ const App = () => (
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/documentation"
|
||||
element={
|
||||
@@ -83,6 +97,30 @@ const App = () => (
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/planning"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PlanningPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/planning/task/:taskId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TaskDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/budget"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BudgetPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/alm/:type"
|
||||
element={
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
AlertCircle,
|
||||
Server,
|
||||
RefreshCw,
|
||||
Loader2
|
||||
Loader2,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { WorkPackage } from '@/types/traceability';
|
||||
import { parseCSVContent, ParseResult } from '@/lib/csvParser';
|
||||
@@ -22,6 +23,9 @@ import { parseCSVContent, ParseResult } from '@/lib/csvParser';
|
||||
const STORAGE_KEY = 'traceability_data';
|
||||
const SERVER_URL_KEY = 'traceability_server_url';
|
||||
|
||||
// API URL for production deployment
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
interface DataUpdateDialogProps {
|
||||
onDataLoaded: (workPackages: WorkPackage[]) => 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<string, number>);
|
||||
|
||||
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
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Upload a CSV file or fetch from your server
|
||||
Upload a CSV file, reload from static file, or sync from OpenProject
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -212,15 +354,15 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload CSV
|
||||
Upload / Reload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="server" className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
Server Fetch
|
||||
Server Sync
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1: Manual CSV Upload */}
|
||||
{/* Tab 1: Manual CSV Upload / Reload */}
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
@@ -247,45 +389,77 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReloadFromCSV}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Loading...</>
|
||||
) : (
|
||||
<><Download className="h-4 w-4 mr-2" /> Reload from Static CSV</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run <code className="bg-muted px-1 rounded">python get_traceability.py</code> to generate the CSV file
|
||||
Run <code className="bg-muted px-1 rounded">python get_traceability.py</code> to generate the CSV file, then reload.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Server Fetch */}
|
||||
{/* Tab 2: Server Sync */}
|
||||
<TabsContent value="server" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="serverUrl">Server Endpoint</Label>
|
||||
<Label htmlFor="serverUrl">API Endpoint</Label>
|
||||
<Input
|
||||
id="serverUrl"
|
||||
placeholder="/api/traceability or https://your-server.com/api/data"
|
||||
placeholder="/api/traceability or https://your-api.com/api/traceability"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Endpoint that runs your Python script and returns CSV or JSON
|
||||
Uses the data service API to sync with OpenProject
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={handleSyncFromServer}
|
||||
disabled={isLoading || !serverUrl}
|
||||
>
|
||||
{isLoading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Syncing...</>
|
||||
) : (
|
||||
<><RefreshCw className="h-4 w-4 mr-2" /> Sync from OpenProject</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFetchFromServer}
|
||||
disabled={isLoading || !serverUrl}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Fetching...</>
|
||||
) : (
|
||||
<><Server className="h-4 w-4 mr-2" /> Fetch from Server</>
|
||||
<><Server className="h-4 w-4 mr-2" /> Fetch Existing Data</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-xs space-y-2">
|
||||
<p className="font-medium">Server Setup:</p>
|
||||
<ol className="list-decimal ml-4 space-y-1 text-muted-foreground">
|
||||
<li>Create an endpoint that runs <code>get_traceability.py</code></li>
|
||||
<li>Return the CSV file or JSON with work packages</li>
|
||||
<li>Example: <code>GET /api/traceability</code> → returns CSV</li>
|
||||
</ol>
|
||||
<p className="font-medium">How it works:</p>
|
||||
<ul className="list-disc ml-4 space-y-1 text-muted-foreground">
|
||||
<li><strong>Sync from OpenProject</strong>: Runs the Python script on the server to fetch latest data from OpenProject API</li>
|
||||
<li><strong>Fetch Existing Data</strong>: Gets the last synced CSV from the server (no OpenProject call)</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Note: Server sync only works in deployed environment with the data-service running.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -337,7 +511,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
|
||||
|
||||
{/* Logs */}
|
||||
{logs.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<details className="text-xs" open>
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
View logs ({logs.length} entries)
|
||||
</summary>
|
||||
|
||||
199
src/components/budget/BudgetSettingsPanel.tsx
Normal file
199
src/components/budget/BudgetSettingsPanel.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
Budget Settings
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Currency */}
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select value={currency} onValueChange={setCurrency}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono w-8">{c.symbol}</span>
|
||||
<span>{c.name} ({c.code})</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Fiscal Year Start */}
|
||||
<div className="space-y-2">
|
||||
<Label>Fiscal Year Start</Label>
|
||||
<Select
|
||||
value={fiscalYearStart.toString()}
|
||||
onValueChange={(v) => setFiscalYearStart(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value.toString()}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The first month of your fiscal year for reporting purposes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Low Runway Warning */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="runwayWarning">Low Runway Warning (months)</Label>
|
||||
<Input
|
||||
id="runwayWarning"
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={lowRunwayWarning}
|
||||
onChange={(e) => setLowRunwayWarning(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show warning when runway drops below this many months
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reset Data */}
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Reset to Sample Data</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Restore all budget data to initial sample values
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
158
src/components/budget/BudgetSummaryCards.tsx
Normal file
158
src/components/budget/BudgetSummaryCards.tsx
Normal file
@@ -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 (
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
<div className="h-8 w-8 bg-muted rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-32 bg-muted rounded mb-2" />
|
||||
<div className="h-3 w-20 bg-muted rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total Balance */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Balance
|
||||
</CardTitle>
|
||||
<Wallet className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn(
|
||||
"text-2xl font-bold",
|
||||
summary.totalBalance >= 0 ? "text-green-600 dark:text-green-400" : "text-destructive"
|
||||
)}>
|
||||
{formatCurrency(summary.totalBalance)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
{formatCurrency(summary.totalIncome)} in
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<ArrowDownRight className="h-3 w-3" />
|
||||
{formatCurrency(summary.totalExpenses)} out
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monthly Burn Rate */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Monthly Burn Rate
|
||||
</CardTitle>
|
||||
<TrendingDown className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">
|
||||
{formatCurrency(summary.monthlyBurnRate)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Average over last 6 months
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Estimated Runway */}
|
||||
<Card className={cn(isRunwayLow && "border-destructive/50 bg-destructive/5")}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Estimated Runway
|
||||
</CardTitle>
|
||||
{isRunwayLow ? (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn(
|
||||
"text-2xl font-bold",
|
||||
isRunwayLow ? "text-destructive" : "text-primary"
|
||||
)}>
|
||||
{summary.estimatedRunway === Infinity
|
||||
? '∞'
|
||||
: `${summary.estimatedRunway} months`
|
||||
}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isRunwayLow
|
||||
? 'Warning: Low runway!'
|
||||
: 'At current burn rate'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Budget Alerts */}
|
||||
<Card className={cn(totalAlerts > 0 && "border-amber-500/50 bg-amber-500/5")}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Budget Alerts
|
||||
</CardTitle>
|
||||
{overBudgetCount > 0 ? (
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
) : warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn(
|
||||
"text-2xl font-bold",
|
||||
overBudgetCount > 0 ? "text-destructive" : warningCount > 0 ? "text-amber-500" : "text-green-600 dark:text-green-400"
|
||||
)}>
|
||||
{totalAlerts === 0 ? 'All Good' : `${totalAlerts} Alert${totalAlerts > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{overBudgetCount > 0
|
||||
? `${overBudgetCount} over budget`
|
||||
: warningCount > 0
|
||||
? `${warningCount} approaching limit`
|
||||
: 'No budget concerns'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
417
src/components/budget/BudgetTargetsManager.tsx
Normal file
417
src/components/budget/BudgetTargetsManager.tsx
Normal file
@@ -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<BudgetTarget | null>(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<BudgetTarget> }) =>
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
Budget Targets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>Loading...</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
Budget Targets
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTarget(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
disabled={availableCategories.length === 0}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Target
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Alerts Section */}
|
||||
{categoryAlerts.some(a => a.isOverBudget || a.isWarning) && (
|
||||
<div className="mb-6 space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Current Month Alerts</h4>
|
||||
{categoryAlerts
|
||||
.filter(a => a.isOverBudget || a.isWarning)
|
||||
.map((alert) => (
|
||||
<div
|
||||
key={alert.category}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
alert.isOverBudget
|
||||
? 'bg-destructive/10 border-destructive/50'
|
||||
: 'bg-yellow-500/10 border-yellow-500/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{alert.isOverBudget ? (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{CATEGORY_ICONS[alert.category]}
|
||||
</span>
|
||||
<span className="font-medium">{alert.category}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono">
|
||||
{formatCurrency(alert.spent)} / {formatCurrency(alert.limit)}
|
||||
</p>
|
||||
<p className={`text-xs ${
|
||||
alert.isOverBudget ? 'text-destructive' : 'text-yellow-500'
|
||||
}`}>
|
||||
{alert.percentUsed.toFixed(0)}% used
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Target className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No budget targets set yet.</p>
|
||||
<p className="text-sm">Add targets to monitor category spending limits.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Monthly Limit</TableHead>
|
||||
<TableHead className="text-right">Alert At</TableHead>
|
||||
<TableHead>This Month</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{targets.map((target) => {
|
||||
const alert = categoryAlerts.find(a => a.category === target.category);
|
||||
const percentUsed = alert?.percentUsed || 0;
|
||||
const spent = alert?.spent || 0;
|
||||
|
||||
return (
|
||||
<TableRow key={target.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{CATEGORY_ICONS[target.category]}
|
||||
</span>
|
||||
{target.category}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatCurrency(target.monthlyLimit)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{target.alertThreshold}%
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span>{formatCurrency(spent)}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{percentUsed >= 100 ? (
|
||||
<XCircle className="h-3 w-3 text-destructive" />
|
||||
) : percentUsed >= target.alertThreshold ? (
|
||||
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
{percentUsed.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(percentUsed, 100)}
|
||||
className={`h-2 ${
|
||||
percentUsed >= 100
|
||||
? '[&>div]:bg-destructive'
|
||||
: percentUsed >= target.alertThreshold
|
||||
? '[&>div]:bg-yellow-500'
|
||||
: '[&>div]:bg-green-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(target)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(target.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<TargetDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
target={editingTarget}
|
||||
availableCategories={editingTarget ? EXPENSE_CATEGORIES : availableCategories}
|
||||
onSave={(data) => {
|
||||
if (editingTarget) {
|
||||
updateMutation.mutate({ id: editingTarget.id, updates: data });
|
||||
} else {
|
||||
createMutation.mutate(data as any);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface TargetDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target?: BudgetTarget | null;
|
||||
availableCategories: ExpenseCategory[];
|
||||
onSave: (data: Partial<BudgetTarget>) => void;
|
||||
}
|
||||
|
||||
function TargetDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
target,
|
||||
availableCategories,
|
||||
onSave
|
||||
}: TargetDialogProps) {
|
||||
const [category, setCategory] = useState<ExpenseCategory>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{target ? 'Edit Budget Target' : 'Add Budget Target'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => setCategory(v as ExpenseCategory)}
|
||||
disabled={!!target}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(target ? [target.category] : availableCategories).map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
<div className="flex items-center gap-2">
|
||||
{CATEGORY_ICONS[cat]}
|
||||
{cat}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthlyLimit">Monthly Limit</Label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="monthlyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={monthlyLimit}
|
||||
onChange={(e) => setMonthlyLimit(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="alertThreshold">Alert Threshold (%)</Label>
|
||||
<Input
|
||||
id="alertThreshold"
|
||||
type="number"
|
||||
min="50"
|
||||
max="99"
|
||||
value={alertThreshold}
|
||||
onChange={(e) => setAlertThreshold(e.target.value)}
|
||||
placeholder="80"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show warning when spending reaches this percentage of the limit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!monthlyLimit || parseFloat(monthlyLimit) <= 0}
|
||||
>
|
||||
{target ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
418
src/components/budget/EquityManager.tsx
Normal file
418
src/components/budget/EquityManager.tsx
Normal file
@@ -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<Founder, 'id' | 'createdAt' | 'updatedAt' | 'totalContributed'>) => 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{founder ? 'Edit Founder' : 'Add Founder'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Founder name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="initialContribution">Initial Contribution ($)</Label>
|
||||
<Input
|
||||
id="initialContribution"
|
||||
type="number"
|
||||
min="0"
|
||||
value={initialContribution}
|
||||
onChange={(e) => setInitialContribution(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="additionalFunding">Additional Funding ($)</Label>
|
||||
<Input
|
||||
id="additionalFunding"
|
||||
type="number"
|
||||
min="0"
|
||||
value={additionalFunding}
|
||||
onChange={(e) => setAdditionalFunding(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sharePercentage">Share Percentage (%)</Label>
|
||||
<Input
|
||||
id="sharePercentage"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={sharePercentage}
|
||||
onChange={(e) => setSharePercentage(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || !sharePercentage}
|
||||
>
|
||||
{founder ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<Founder | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedFounder(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (founder: Founder) => {
|
||||
setSelectedFounder(founder);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = (data: Omit<Founder, 'id' | 'createdAt' | 'updatedAt' | 'totalContributed'>) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Founders
|
||||
</CardTitle>
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{founders.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{totalShares.toFixed(1)}% allocated
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Contributions
|
||||
</CardTitle>
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{formatCurrency(totalContributions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
From all founders
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Equity Distribution Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equity Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pieData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No founders added yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: ${value}%`}
|
||||
labelLine={false}
|
||||
>
|
||||
{pieData.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number) => `${value}%`}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Founders Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Founders</CardTitle>
|
||||
<Button size="sm" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Founder
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48">Loading...</div>
|
||||
) : founders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<Users className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p>No founders added yet.</p>
|
||||
<Button variant="link" onClick={handleCreate}>
|
||||
Add your first founder
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="text-right">Contributed</TableHead>
|
||||
<TableHead className="text-right">Share %</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{founders.map((founder, index) => (
|
||||
<TableRow key={founder.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="font-medium">{founder.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{formatCurrency(founder.totalContributed)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono font-bold">
|
||||
{founder.sharePercentage}%
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(founder)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(founder.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Founder Dialog */}
|
||||
<FounderDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
founder={selectedFounder}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Founder?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will remove the founder from the equity table.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Remove</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/components/budget/ExpenseBreakdownChart.tsx
Normal file
228
src/components/budget/ExpenseBreakdownChart.tsx
Normal file
@@ -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<string, string> = {
|
||||
'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<string, React.ReactNode> = {
|
||||
'Purchase': <ShoppingCart className="h-4 w-4" />,
|
||||
'Salary': <Users className="h-4 w-4" />,
|
||||
'Cloud/Subscription': <Server className="h-4 w-4" />,
|
||||
'Marketing': <Megaphone className="h-4 w-4" />,
|
||||
'Rent': <Building className="h-4 w-4" />,
|
||||
'Utilities': <Zap className="h-4 w-4" />,
|
||||
'Hardware': <Cpu className="h-4 w-4" />,
|
||||
'Software': <Package className="h-4 w-4" />,
|
||||
'Travel': <Plane className="h-4 w-4" />,
|
||||
'Legal': <Scale className="h-4 w-4" />,
|
||||
'Insurance': <Shield className="h-4 w-4" />,
|
||||
'Office Supplies': <FileText className="h-4 w-4" />,
|
||||
'Consulting': <Briefcase className="h-4 w-4" />,
|
||||
'Other': <HelpCircle className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Financial Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center h-64">
|
||||
Loading charts...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Financial Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="breakdown" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="breakdown">Expense Breakdown</TabsTrigger>
|
||||
<TabsTrigger value="trends">Monthly Trends</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="breakdown">
|
||||
{pieData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No expense data yet. Add transactions to see breakdown.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Pie Chart */}
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={CATEGORY_COLORS[entry.name] || CATEGORY_COLORS['Other']}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{pieData.map((entry) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: CATEGORY_COLORS[entry.name] || CATEGORY_COLORS['Other'] }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{CATEGORY_ICONS[entry.name] || CATEGORY_ICONS['Other']}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{entry.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends">
|
||||
{barData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No monthly data yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
className="text-xs fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs fill-muted-foreground"
|
||||
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="income"
|
||||
name="Income"
|
||||
fill="hsl(142 76% 36%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="expenses"
|
||||
name="Expenses"
|
||||
fill="hsl(340 82% 52%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export { CATEGORY_COLORS, CATEGORY_ICONS };
|
||||
257
src/components/budget/FinancialProjections.tsx
Normal file
257
src/components/budget/FinancialProjections.tsx
Normal file
@@ -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<string, any>[] = [];
|
||||
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<string, any> = { 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<string, number> = {};
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">Loading projections...</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const lowRunwayThreshold = settings?.lowRunwayWarning || 6;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Scenario Summary Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{SCENARIOS.map((scenario) => {
|
||||
const runway = runwayByScenario[scenario.name] || 0;
|
||||
const isLow = runway !== Infinity && runway <= lowRunwayThreshold;
|
||||
const isCritical = runway !== Infinity && runway <= 3;
|
||||
|
||||
return (
|
||||
<Card key={scenario.name} className="relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-1"
|
||||
style={{ backgroundColor: scenario.color }}
|
||||
/>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span>{scenario.name} Scenario</span>
|
||||
{scenario.name === 'Optimistic' && <TrendingUp className="h-4 w-4 text-emerald-500" />}
|
||||
{scenario.name === 'Current' && <Sparkles className="h-4 w-4 text-blue-500" />}
|
||||
{scenario.name === 'Pessimistic' && <TrendingDown className="h-4 w-4 text-rose-500" />}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{runway === Infinity ? '∞' : `${runway} months`}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{scenario.description}</p>
|
||||
{isLow && (
|
||||
<Badge variant={isCritical ? 'destructive' : 'secondary'} className="mt-2">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
{isCritical ? 'Critical' : 'Low Runway'}
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Projection Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Projection Parameters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Label>Projection Period</Label>
|
||||
<span className="text-sm font-medium">{monthsToProject} months</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[monthsToProject]}
|
||||
onValueChange={([v]) => setMonthsToProject(v)}
|
||||
min={6}
|
||||
max={36}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Label>Additional Monthly Income</Label>
|
||||
<span className="text-sm font-medium">{formatCurrency(additionalMonthlyIncome)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[additionalMonthlyIncome]}
|
||||
onValueChange={([v]) => setAdditionalMonthlyIncome(v)}
|
||||
min={0}
|
||||
max={100000}
|
||||
step={1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Current Balance</div>
|
||||
<div className="text-lg font-semibold">{formatCurrency(summary.totalBalance)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Monthly Burn</div>
|
||||
<div className="text-lg font-semibold text-rose-500">{formatCurrency(summary.monthlyBurnRate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Recurring Income</div>
|
||||
<div className="text-lg font-semibold text-emerald-500">{formatCurrency(monthlyRecurringIncome)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Recurring Expenses</div>
|
||||
<div className="text-lg font-semibold text-orange-500">{formatCurrency(monthlyRecurringExpense)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Projection Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Balance Projection</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={projectionData}>
|
||||
<defs>
|
||||
{SCENARIOS.map((scenario) => (
|
||||
<linearGradient key={scenario.name} id={`gradient-${scenario.name}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={scenario.color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={scenario.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
className="text-xs fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs fill-muted-foreground"
|
||||
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
{SCENARIOS.map((scenario) => (
|
||||
<Area
|
||||
key={scenario.name}
|
||||
type="monotone"
|
||||
dataKey={scenario.name}
|
||||
stroke={scenario.color}
|
||||
fill={`url(#gradient-${scenario.name})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
src/components/budget/RecurringTransactionManager.tsx
Normal file
335
src/components/budget/RecurringTransactionManager.tsx
Normal file
@@ -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<RecurringTransaction | null>(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 <Card><CardContent className="py-8 text-center">Loading...</CardContent></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Repeat className="h-5 w-5" />
|
||||
Recurring Transactions
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => processMutation.mutate()}
|
||||
disabled={processMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${processMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
Process Due
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Recurring
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit' : 'Add'} Recurring Transaction</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Monthly AWS Bill"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(v) => setFormData({ ...formData, type: v as 'Income' | 'Expense', category: v === 'Income' ? 'Revenue' : 'Cloud/Subscription' })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Expense">Expense</SelectItem>
|
||||
<SelectItem value="Income">Income</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Frequency</Label>
|
||||
<Select
|
||||
value={formData.frequency}
|
||||
onValueChange={(v) => setFormData({ ...formData, frequency: v as RecurringFrequency })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
<SelectItem value="yearly">Yearly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(v) => setFormData({ ...formData, category: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Next Run Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.nextRunDate}
|
||||
onChange={(e) => setFormData({ ...formData, nextRunDate: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Active</Label>
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editing ? 'Update' : 'Create'} Recurring Transaction
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recurring.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No recurring transactions set up yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Frequency</TableHead>
|
||||
<TableHead>Next Run</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recurring.map((item) => (
|
||||
<TableRow key={item.id} className={!item.isActive ? 'opacity-50' : ''}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.category}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={item.type === 'Income' ? 'text-emerald-500' : 'text-rose-500'}>
|
||||
{item.type === 'Income' ? '+' : '-'}{formatCurrency(item.amount)}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">{item.frequency}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{format(new Date(item.nextRunDate), 'MMM d, yyyy')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.isActive ? 'default' : 'secondary'}>
|
||||
{item.isActive ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleActive(item)}
|
||||
>
|
||||
<Switch checked={item.isActive} className="pointer-events-none" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
207
src/components/budget/TransactionAttachmentUpload.tsx
Normal file
207
src/components/budget/TransactionAttachmentUpload.tsx
Normal file
@@ -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<HTMLInputElement>(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<string>((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 <ImageIcon className="h-4 w-4" />;
|
||||
if (type.includes('pdf')) return <FileText className="h-4 w-4" />;
|
||||
return <File className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
<Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drop files here or click to upload
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Images, PDFs, Documents (max {maxSize}MB each)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attachments List */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center justify-between p-2 rounded-lg bg-muted/50 border"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{attachment.type.startsWith('image/') ? (
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.name}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center">
|
||||
{getFileIcon(attachment.type)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{attachment.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(attachment.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(attachment);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(attachment.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
src/components/budget/TransactionDialog.tsx
Normal file
230
src/components/budget/TransactionDialog.tsx
Normal file
@@ -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<Transaction, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
}
|
||||
|
||||
export function TransactionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onSave
|
||||
}: TransactionDialogProps) {
|
||||
const [type, setType] = useState<TransactionType>(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<string>(transaction?.category ?? 'Other');
|
||||
const [amount, setAmount] = useState(transaction?.amount?.toString() ?? '');
|
||||
const [attachments, setAttachments] = useState<TransactionAttachment[]>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{transaction ? 'Edit Transaction' : 'Add Transaction'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Transaction Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<RadioGroup
|
||||
value={type}
|
||||
onValueChange={(v) => handleTypeChange(v as TransactionType)}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Expense" id="expense" />
|
||||
<Label htmlFor="expense" className="text-red-600 dark:text-red-400 cursor-pointer">
|
||||
Expense
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Income" id="income" />
|
||||
<Label htmlFor="income" className="text-green-600 dark:text-green-400 cursor-pointer">
|
||||
Income
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What was this transaction for?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
<div className="flex items-center gap-2">
|
||||
{CATEGORY_ICONS[cat] || null}
|
||||
{cat}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<Collapsible open={showAttachments} onOpenChange={setShowAttachments}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between px-0 hover:bg-transparent">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Attachments
|
||||
{attachments.length > 0 && (
|
||||
<span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showAttachments ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<TransactionAttachmentUpload
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!date || !description.trim() || !amount || parseFloat(amount) <= 0}
|
||||
>
|
||||
{transaction ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
401
src/components/budget/TransactionManager.tsx
Normal file
401
src/components/budget/TransactionManager.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
useTransactions,
|
||||
useCreateTransaction,
|
||||
useUpdateTransaction,
|
||||
useDeleteTransaction,
|
||||
useExportTransactions,
|
||||
useImportTransactions,
|
||||
} from '@/hooks/useBudget';
|
||||
import { useBudgetContext } from '@/contexts/BudgetContext';
|
||||
import { Transaction, TransactionType, EXPENSE_CATEGORIES } from '@/types/budget';
|
||||
import { TransactionDialog } from './TransactionDialog';
|
||||
import { CATEGORY_ICONS, CATEGORY_COLORS } from './ExpenseBreakdownChart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 {
|
||||
Plus,
|
||||
Search,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Download,
|
||||
Upload,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SortField = 'date' | 'amount' | 'category';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export function TransactionManager() {
|
||||
const { data: transactions = [], isLoading } = useTransactions();
|
||||
const createTransaction = useCreateTransaction();
|
||||
const updateTransaction = useUpdateTransaction();
|
||||
const deleteTransaction = useDeleteTransaction();
|
||||
const exportTransactions = useExportTransactions();
|
||||
const importTransactions = useImportTransactions();
|
||||
const { formatCurrency } = useBudgetContext();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | TransactionType>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
|
||||
// Sorting
|
||||
const [sortField, setSortField] = useState<SortField>('date');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Filter and sort transactions
|
||||
const filteredTransactions = useMemo(() => {
|
||||
let result = [...transactions];
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
result = result.filter(t =>
|
||||
t.description.toLowerCase().includes(searchLower) ||
|
||||
t.category.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all') {
|
||||
result = result.filter(t => t.type === typeFilter);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (categoryFilter !== 'all') {
|
||||
result = result.filter(t => t.category === categoryFilter);
|
||||
}
|
||||
|
||||
// Sort
|
||||
result.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case 'date':
|
||||
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
break;
|
||||
case 'amount':
|
||||
comparison = a.amount - b.amount;
|
||||
break;
|
||||
case 'category':
|
||||
comparison = a.category.localeCompare(b.category);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [transactions, search, typeFilter, categoryFilter, sortField, sortOrder]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedTransaction(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (transaction: Transaction) => {
|
||||
setSelectedTransaction(transaction);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = (data: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (selectedTransaction) {
|
||||
updateTransaction.mutate({ id: selectedTransaction.id, updates: data });
|
||||
} else {
|
||||
createTransaction.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteId) {
|
||||
deleteTransaction.mutate(deleteId);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
importTransactions.mutate(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
return sortOrder === 'asc'
|
||||
? <ArrowUp className="h-4 w-4 ml-1" />
|
||||
: <ArrowDown className="h-4 w-4 ml-1" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportTransactions.mutate()}
|
||||
disabled={transactions.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Transaction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search transactions..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as any)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="Income">Income</SelectItem>
|
||||
<SelectItem value="Expense">Expense</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{EXPENSE_CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48">Loading...</div>
|
||||
) : filteredTransactions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<p>No transactions found.</p>
|
||||
{transactions.length === 0 && (
|
||||
<Button variant="link" onClick={handleCreate}>
|
||||
Add your first transaction
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('date')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Date <SortIcon field="date" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Category <SortIcon field="category" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-right cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('amount')}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
Amount <SortIcon field="amount" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTransactions.map((transaction) => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell className="font-medium">
|
||||
{format(new Date(transaction.date), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
transaction.type === 'Income' ? 'bg-green-500' : 'bg-red-500'
|
||||
)} />
|
||||
{transaction.description}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
style={{
|
||||
backgroundColor: `${CATEGORY_COLORS[transaction.category]}20`,
|
||||
borderColor: CATEGORY_COLORS[transaction.category],
|
||||
}}
|
||||
>
|
||||
{CATEGORY_ICONS[transaction.category]}
|
||||
{transaction.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={cn(
|
||||
"text-right font-mono",
|
||||
transaction.type === 'Income'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}>
|
||||
{transaction.type === 'Income' ? '+' : '-'}
|
||||
{formatCurrency(transaction.amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(transaction)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(transaction.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Transaction Dialog */}
|
||||
<TransactionDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
transaction={selectedTransaction}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Transaction?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the transaction.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,13 @@ import {
|
||||
Cpu,
|
||||
Share2,
|
||||
Thermometer,
|
||||
ClipboardList,
|
||||
Shield,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -37,6 +41,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const mainItems = [
|
||||
{ title: "Dashboard", url: "/", icon: LayoutDashboard },
|
||||
{ title: "Planning", url: "/planning", icon: ClipboardList },
|
||||
{ title: "Budget", url: "/budget", icon: Wallet },
|
||||
{ title: "Traceability Matrix", url: "/matrix", icon: GitBranch },
|
||||
{ title: "Work Package Graph", url: "/graph", icon: Share2 },
|
||||
{ title: "Documentation", url: "/documentation", icon: BookOpen },
|
||||
@@ -68,6 +74,7 @@ export function AppSidebar() {
|
||||
const collapsed = state === "collapsed";
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const [almExpanded, setAlmExpanded] = useState(
|
||||
almItems.some((item) => currentPath.startsWith(item.url))
|
||||
@@ -120,11 +127,28 @@ export function AppSidebar() {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{/* Admin link - only visible to admins */}
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"text-sidebar-foreground"
|
||||
)}
|
||||
activeClassName="bg-sidebar-accent text-sidebar-primary font-medium"
|
||||
>
|
||||
<Shield className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>Admin</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* ALM Items */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel
|
||||
className="cursor-pointer flex items-center justify-between text-sidebar-foreground/60 hover:text-sidebar-foreground"
|
||||
|
||||
255
src/components/planning/AttachmentUpload.tsx
Normal file
255
src/components/planning/AttachmentUpload.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Attachment } from '@/types/planning';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Paperclip,
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
FileText,
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AttachmentUploadProps {
|
||||
attachments: Attachment[];
|
||||
onChange: (attachments: Attachment[]) => void;
|
||||
maxFiles?: number;
|
||||
maxSizeMB?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
export function AttachmentUpload({
|
||||
attachments,
|
||||
onChange,
|
||||
maxFiles = 5,
|
||||
maxSizeMB = 5,
|
||||
disabled = false
|
||||
}: AttachmentUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const remainingSlots = maxFiles - attachments.length;
|
||||
if (remainingSlots <= 0) {
|
||||
toast.error(`Maximum ${maxFiles} files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToProcess = Array.from(files).slice(0, remainingSlots);
|
||||
const maxSize = maxSizeMB * 1024 * 1024;
|
||||
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
if (file.size > maxSize) {
|
||||
toast.error(`${file.name} exceeds ${maxSizeMB}MB limit`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
newAttachments.push({
|
||||
id: generateId(),
|
||||
name: file.name,
|
||||
url: dataUrl,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Failed to read ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
onChange([...attachments, ...newAttachments]);
|
||||
};
|
||||
|
||||
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(attachments.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (!disabled) handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const formatSize = (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`;
|
||||
};
|
||||
|
||||
const isImage = (type: string) => type.startsWith('image/');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
|
||||
isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:border-primary/50"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<Paperclip className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drop files here or click to upload
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Max {maxFiles} files, {maxSizeMB}MB each
|
||||
</p>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
disabled={disabled}
|
||||
accept="image/*,.pdf,.doc,.docx,.txt,.md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments list */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg bg-muted/50 group"
|
||||
>
|
||||
{/* Preview/Icon */}
|
||||
{isImage(attachment.type) ? (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex items-center justify-center rounded bg-muted">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{attachment.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSize(attachment.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isImage(attachment.type) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(attachment.url, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
href={attachment.url}
|
||||
download={attachment.name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(attachment.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only version for displaying attachments
|
||||
interface AttachmentListProps {
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
export function AttachmentList({ attachments }: AttachmentListProps) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
const formatSize = (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`;
|
||||
};
|
||||
|
||||
const isImage = (type: string) => type.startsWith('image/');
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download={!isImage(attachment.type) ? attachment.name : undefined}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded bg-muted/50 hover:bg-muted text-xs transition-colors"
|
||||
>
|
||||
{isImage(attachment.type) ? (
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="max-w-[120px] truncate">{attachment.name}</span>
|
||||
<span className="text-muted-foreground">({formatSize(attachment.size)})</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
443
src/components/planning/BacklogView.tsx
Normal file
443
src/components/planning/BacklogView.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { Task, Sprint } from '@/types/planning';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { TaskDialog } from './TaskDialog';
|
||||
import { SprintDialog } from './SprintDialog';
|
||||
import { TaskFilters } from './TaskFilters';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
useTasks,
|
||||
useSprints,
|
||||
useCreateTask,
|
||||
useUpdateTask,
|
||||
useDeleteTask,
|
||||
useCreateSprint,
|
||||
useUpdateSprint,
|
||||
useDeleteSprint,
|
||||
useMoveTask,
|
||||
useStartSprint,
|
||||
useCompleteSprint,
|
||||
} from '@/hooks/usePlanning';
|
||||
import { useTraceabilityData } from '@/hooks/useTraceabilityData';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
CheckCircle,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Target,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export function BacklogView() {
|
||||
const { data: tasks = [], isLoading: tasksLoading } = useTasks();
|
||||
const { data: sprints = [], isLoading: sprintsLoading } = useSprints();
|
||||
const { groupedByType } = useTraceabilityData();
|
||||
const features = groupedByType['feature'] || [];
|
||||
|
||||
const createTask = useCreateTask();
|
||||
const updateTask = useUpdateTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const createSprint = useCreateSprint();
|
||||
const updateSprint = useUpdateSprint();
|
||||
const deleteSprint = useDeleteSprint();
|
||||
const moveTask = useMoveTask();
|
||||
const startSprint = useStartSprint();
|
||||
const completeSprint = useCompleteSprint();
|
||||
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [sprintDialogOpen, setSprintDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [editingSprint, setEditingSprint] = useState<Sprint | null>(null);
|
||||
const [targetSprintId, setTargetSprintId] = useState<string | null>(null);
|
||||
const [deleteSprintId, setDeleteSprintId] = useState<string | null>(null);
|
||||
const [expandedSprints, setExpandedSprints] = useState<Set<string>>(new Set());
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filteredTasks, setFilteredTasks] = useState<Task[]>([]);
|
||||
|
||||
const allBacklogTasks = tasks.filter(t => t.sprintId === null);
|
||||
const backlogTasks = filtersOpen ? filteredTasks.filter(t => t.sprintId === null) : allBacklogTasks;
|
||||
|
||||
const handleFilteredTasksChange = useCallback((filtered: Task[]) => {
|
||||
setFilteredTasks(filtered);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (e: DragEvent, taskId: string) => {
|
||||
e.dataTransfer.setData('taskId', taskId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, sprintId: string | null) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.dataTransfer.getData('taskId');
|
||||
if (taskId) {
|
||||
moveTask.mutate({ taskId, sprintId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskSave = (taskData: any) => {
|
||||
if (taskData.id) {
|
||||
updateTask.mutate({ id: taskData.id, updates: taskData });
|
||||
} else {
|
||||
createTask.mutate(taskData);
|
||||
}
|
||||
setEditingTask(null);
|
||||
};
|
||||
|
||||
const handleSprintSave = (sprintData: any) => {
|
||||
if (sprintData.id) {
|
||||
updateSprint.mutate({ id: sprintData.id, updates: sprintData });
|
||||
} else {
|
||||
createSprint.mutate(sprintData);
|
||||
}
|
||||
setEditingSprint(null);
|
||||
};
|
||||
|
||||
const toggleSprintExpanded = (sprintId: string) => {
|
||||
setExpandedSprints(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sprintId)) {
|
||||
next.delete(sprintId);
|
||||
} else {
|
||||
next.add(sprintId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getSprintTasks = (sprintId: string) => tasks.filter(t => t.sprintId === sprintId);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
planning: 'bg-slate-500/20 text-slate-700 dark:text-slate-300',
|
||||
active: 'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
completed: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
|
||||
};
|
||||
|
||||
if (tasksLoading || sprintsLoading) {
|
||||
return <div className="flex items-center justify-center h-64">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full">
|
||||
{/* Product Backlog */}
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
Product Backlog
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{backlogTasks.length} items {filtersOpen && allBacklogTasks.length !== backlogTasks.length && `(filtered from ${allBacklogTasks.length})`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={cn(filtersOpen && "bg-accent")}
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
Filter
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => {
|
||||
setEditingTask(null);
|
||||
setTargetSprintId(null);
|
||||
setTaskDialogOpen(true);
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{filtersOpen && (
|
||||
<div className="px-4 pb-2">
|
||||
<TaskFilters
|
||||
tasks={allBacklogTasks}
|
||||
onFilteredTasksChange={handleFilteredTasksChange}
|
||||
features={features}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 p-0">
|
||||
<ScrollArea className="h-[calc(100vh-320px)]">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 p-4 min-h-[200px] rounded-lg mx-2 mb-2 transition-colors",
|
||||
"border-2 border-dashed border-transparent",
|
||||
"hover:border-muted-foreground/20"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, null)}
|
||||
>
|
||||
{backlogTasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No items in backlog</p>
|
||||
<p className="text-sm">Create tasks or drag items here</p>
|
||||
</div>
|
||||
) : (
|
||||
backlogTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id)}
|
||||
>
|
||||
<TaskCard
|
||||
task={task}
|
||||
onEdit={(t) => {
|
||||
setEditingTask(t);
|
||||
setTaskDialogOpen(true);
|
||||
}}
|
||||
onDelete={(id) => deleteTask.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sprints */}
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Sprints
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{sprints.length} sprints
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => {
|
||||
setEditingSprint(null);
|
||||
setSprintDialogOpen(true);
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Sprint
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0">
|
||||
<ScrollArea className="h-[calc(100vh-320px)]">
|
||||
<div className="space-y-3 p-4">
|
||||
{sprints.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No sprints created</p>
|
||||
<p className="text-sm">Create a sprint to organize your work</p>
|
||||
</div>
|
||||
) : (
|
||||
sprints.map(sprint => {
|
||||
const sprintTasks = getSprintTasks(sprint.id);
|
||||
const isExpanded = expandedSprints.has(sprint.id);
|
||||
const doneTasks = sprintTasks.filter(t => t.status === 'done').length;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={sprint.id}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleSprintExpanded(sprint.id)}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"border-2 transition-colors",
|
||||
"hover:border-primary/30"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, sprint.id)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-semibold">{sprint.name}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={statusColors[sprint.status]}>
|
||||
{sprint.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(sprint.startDate), 'MMM d')} - {format(new Date(sprint.endDate), 'MMM d')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{doneTasks}/{sprintTasks.length}
|
||||
</Badge>
|
||||
{sprint.status === 'planning' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startSprint.mutate(sprint.id);
|
||||
}}
|
||||
disabled={sprintTasks.length === 0}
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{sprint.status === 'active' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
completeSprint.mutate(sprint.id);
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteSprintId(sprint.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{sprint.goal && (
|
||||
<p className="text-sm text-muted-foreground mt-2 pl-6">
|
||||
{sprint.goal}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2 min-h-[80px] p-2 bg-muted/30 rounded-lg">
|
||||
{sprintTasks.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Drag tasks here
|
||||
</p>
|
||||
) : (
|
||||
sprintTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id)}
|
||||
>
|
||||
<TaskCard
|
||||
task={task}
|
||||
compact
|
||||
onEdit={(t) => {
|
||||
setEditingTask(t);
|
||||
setTaskDialogOpen(true);
|
||||
}}
|
||||
onDelete={(id) => deleteTask.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2"
|
||||
onClick={() => {
|
||||
setEditingTask(null);
|
||||
setTargetSprintId(sprint.id);
|
||||
setTaskDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Task to Sprint
|
||||
</Button>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Task Dialog */}
|
||||
<TaskDialog
|
||||
open={taskDialogOpen}
|
||||
onOpenChange={setTaskDialogOpen}
|
||||
task={editingTask}
|
||||
sprintId={targetSprintId}
|
||||
onSave={handleTaskSave}
|
||||
/>
|
||||
|
||||
{/* Sprint Dialog */}
|
||||
<SprintDialog
|
||||
open={sprintDialogOpen}
|
||||
onOpenChange={setSprintDialogOpen}
|
||||
sprint={editingSprint}
|
||||
onSave={handleSprintSave}
|
||||
/>
|
||||
|
||||
{/* Delete Sprint Confirmation */}
|
||||
<AlertDialog open={!!deleteSprintId} onOpenChange={() => setDeleteSprintId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Sprint?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will move all tasks in this sprint back to the backlog.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteSprintId) {
|
||||
deleteSprint.mutate(deleteSprintId);
|
||||
setDeleteSprintId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/components/planning/BoardView.tsx
Normal file
291
src/components/planning/BoardView.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState, useCallback, DragEvent } from 'react';
|
||||
import { Task, TaskStatus, Sprint } from '@/types/planning';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { TaskDialog } from './TaskDialog';
|
||||
import { TaskFilters } from './TaskFilters';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
useTasks,
|
||||
useSprints,
|
||||
useUpdateTask,
|
||||
useDeleteTask,
|
||||
useUpdateTaskStatus,
|
||||
} from '@/hooks/usePlanning';
|
||||
import { useTraceabilityData } from '@/hooks/useTraceabilityData';
|
||||
import {
|
||||
Circle,
|
||||
Loader2,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const columns: { id: TaskStatus; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ id: 'todo', label: 'To Do', icon: <Circle className="h-4 w-4" />, color: 'border-slate-400' },
|
||||
{ id: 'inprogress', label: 'In Progress', icon: <Loader2 className="h-4 w-4" />, color: 'border-blue-400' },
|
||||
{ id: 'review', label: 'Review', icon: <Eye className="h-4 w-4" />, color: 'border-amber-400' },
|
||||
{ id: 'done', label: 'Done', icon: <CheckCircle2 className="h-4 w-4" />, color: 'border-green-400' },
|
||||
];
|
||||
|
||||
export function BoardView() {
|
||||
const { data: tasks = [], isLoading: tasksLoading } = useTasks();
|
||||
const { data: sprints = [], isLoading: sprintsLoading } = useSprints();
|
||||
const { groupedByType } = useTraceabilityData();
|
||||
const features = groupedByType['feature'] || [];
|
||||
|
||||
const updateTask = useUpdateTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const updateTaskStatus = useUpdateTaskStatus();
|
||||
|
||||
const [selectedSprintId, setSelectedSprintId] = useState<string>('active');
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filteredTasks, setFilteredTasks] = useState<Task[]>([]);
|
||||
|
||||
// Get the active sprint or selected sprint
|
||||
const activeSprint = sprints.find(s => s.status === 'active');
|
||||
const selectedSprint = selectedSprintId === 'active'
|
||||
? activeSprint
|
||||
: sprints.find(s => s.id === selectedSprintId);
|
||||
|
||||
const sprintTasks = selectedSprint
|
||||
? tasks.filter(t => t.sprintId === selectedSprint.id)
|
||||
: [];
|
||||
|
||||
// Use filtered tasks if filters are applied, otherwise use all sprint tasks
|
||||
const displayTasks = filtersOpen ? filteredTasks : sprintTasks;
|
||||
|
||||
const handleFilteredTasksChange = useCallback((filtered: Task[]) => {
|
||||
setFilteredTasks(filtered);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (e: DragEvent, taskId: string) => {
|
||||
e.dataTransfer.setData('taskId', taskId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent, columnId: TaskStatus) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverColumn(columnId);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, status: TaskStatus) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.dataTransfer.getData('taskId');
|
||||
if (taskId) {
|
||||
updateTaskStatus.mutate({ taskId, status });
|
||||
}
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const handleTaskSave = (taskData: any) => {
|
||||
if (taskData.id) {
|
||||
updateTask.mutate({ id: taskData.id, updates: taskData });
|
||||
}
|
||||
setEditingTask(null);
|
||||
};
|
||||
|
||||
const getColumnTasks = (status: TaskStatus) =>
|
||||
displayTasks.filter(t => t.status === status);
|
||||
|
||||
if (tasksLoading || sprintsLoading) {
|
||||
return <div className="flex items-center justify-center h-64">Loading...</div>;
|
||||
}
|
||||
|
||||
const hasActiveSprint = !!activeSprint;
|
||||
const hasAnySprints = sprints.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sprint Selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={selectedSprintId} onValueChange={setSelectedSprintId}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select sprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasActiveSprint && (
|
||||
<SelectItem value="active">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-green-500/20 text-green-700 dark:text-green-400">Active</Badge>
|
||||
{activeSprint.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
{sprints.map(sprint => (
|
||||
<SelectItem key={sprint.id} value={sprint.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sprint.status}
|
||||
</Badge>
|
||||
{sprint.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedSprint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedSprint.goal}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSprint && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={cn(filtersOpen && "bg-accent")}
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
<Badge variant="secondary">
|
||||
{sprintTasks.filter(t => t.status === 'done').length}/{sprintTasks.length} done
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{selectedSprint && (
|
||||
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<CollapsibleContent>
|
||||
<Card className="p-4">
|
||||
<TaskFilters
|
||||
tasks={sprintTasks}
|
||||
onFilteredTasksChange={handleFilteredTasksChange}
|
||||
features={features}
|
||||
/>
|
||||
</Card>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Empty States */}
|
||||
{!hasAnySprints && (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="font-semibold text-lg mb-2">No Sprints Created</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Go to the Backlog view to create sprints and add tasks.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasAnySprints && !selectedSprint && (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="font-semibold text-lg mb-2">No Active Sprint</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Start a sprint from the Backlog view or select a sprint above.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Kanban Board */}
|
||||
{selectedSprint && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{columns.map(column => {
|
||||
const columnTasks = getColumnTasks(column.id);
|
||||
const isDragOver = dragOverColumn === column.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={column.id}
|
||||
className={cn(
|
||||
"flex flex-col transition-all duration-200",
|
||||
`border-t-4 ${column.color}`,
|
||||
isDragOver && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
onDragOver={(e) => handleDragOver(e, column.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, column.id)}
|
||||
>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{column.icon}
|
||||
{column.label}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{columnTasks.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-2">
|
||||
<ScrollArea className="h-[calc(100vh-320px)]">
|
||||
<div className={cn(
|
||||
"space-y-2 min-h-[100px] p-2 rounded-lg transition-colors",
|
||||
isDragOver && "bg-primary/5"
|
||||
)}>
|
||||
{columnTasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
Drop tasks here
|
||||
</div>
|
||||
) : (
|
||||
columnTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id)}
|
||||
>
|
||||
<TaskCard
|
||||
task={task}
|
||||
compact
|
||||
onEdit={(t) => {
|
||||
setEditingTask(t);
|
||||
setTaskDialogOpen(true);
|
||||
}}
|
||||
onDelete={(id) => deleteTask.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Dialog */}
|
||||
<TaskDialog
|
||||
open={taskDialogOpen}
|
||||
onOpenChange={setTaskDialogOpen}
|
||||
task={editingTask}
|
||||
onSave={handleTaskSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
src/components/planning/GanttView.tsx
Normal file
264
src/components/planning/GanttView.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { usePlanningData } from '@/hooks/usePlanning';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Task, TaskType, TaskPriority } from '@/types/planning';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Bug,
|
||||
CheckSquare,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { format, startOfWeek, endOfWeek, addWeeks, subWeeks, eachDayOfInterval, isSameDay, isWithinInterval, differenceInDays, addDays, parseISO, isValid } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const typeIcons: Record<TaskType, React.ReactNode> = {
|
||||
Story: <BookOpen className="h-3.5 w-3.5" />,
|
||||
Bug: <Bug className="h-3.5 w-3.5" />,
|
||||
Task: <CheckSquare className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
Story: 'bg-green-500',
|
||||
Bug: 'bg-red-500',
|
||||
Task: 'bg-blue-500',
|
||||
};
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
High: 'border-l-red-500',
|
||||
Med: 'border-l-amber-500',
|
||||
Low: 'border-l-slate-500',
|
||||
};
|
||||
|
||||
export function GanttView() {
|
||||
const { data, isLoading } = usePlanningData();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [weeksToShow] = useState(4);
|
||||
|
||||
// Get tasks with due dates (only Stories and Tasks, not sub-tasks)
|
||||
const tasksWithDueDates = useMemo(() => {
|
||||
if (!data?.tasks) return [];
|
||||
return data.tasks
|
||||
.filter(t => t.dueDate && (t.type === 'Story' || t.type === 'Task' || t.type === 'Bug'))
|
||||
.sort((a, b) => {
|
||||
const dateA = a.dueDate ? new Date(a.dueDate).getTime() : 0;
|
||||
const dateB = b.dueDate ? new Date(b.dueDate).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
});
|
||||
}, [data?.tasks]);
|
||||
|
||||
// Calculate date range
|
||||
const startDate = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const endDate = endOfWeek(addWeeks(currentDate, weeksToShow - 1), { weekStartsOn: 1 });
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
const totalDays = days.length;
|
||||
|
||||
const navigateWeeks = (direction: 'prev' | 'next') => {
|
||||
setCurrentDate(prev => direction === 'prev' ? subWeeks(prev, 1) : addWeeks(prev, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
const getTaskPosition = (task: Task) => {
|
||||
if (!task.dueDate) return null;
|
||||
|
||||
const dueDate = parseISO(task.dueDate);
|
||||
if (!isValid(dueDate)) return null;
|
||||
|
||||
// Task bar starts from creation date (or start of view) to due date
|
||||
const createdDate = parseISO(task.createdAt);
|
||||
const taskStart = createdDate > startDate ? createdDate : startDate;
|
||||
const taskEnd = dueDate;
|
||||
|
||||
if (taskEnd < startDate || taskStart > endDate) return null;
|
||||
|
||||
const effectiveStart = taskStart < startDate ? startDate : taskStart;
|
||||
const effectiveEnd = taskEnd > endDate ? endDate : taskEnd;
|
||||
|
||||
const startOffset = differenceInDays(effectiveStart, startDate);
|
||||
const duration = differenceInDays(effectiveEnd, effectiveStart) + 1;
|
||||
|
||||
return {
|
||||
left: `${(startOffset / totalDays) * 100}%`,
|
||||
width: `${(duration / totalDays) * 100}%`,
|
||||
};
|
||||
};
|
||||
|
||||
const isOverdue = (task: Task) => {
|
||||
if (!task.dueDate || task.status === 'done') return false;
|
||||
return new Date(task.dueDate) < new Date();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center h-64">
|
||||
Loading Gantt chart...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Gantt Chart
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigateWeeks('prev')}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => navigateWeeks('next')}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{format(startDate, 'MMM d, yyyy')} - {format(endDate, 'MMM d, yyyy')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasksWithDueDates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<Calendar className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p>No tasks with due dates.</p>
|
||||
<p className="text-sm">Add due dates to your tasks to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="min-w-[800px]">
|
||||
{/* Timeline Header */}
|
||||
<div className="flex border-b">
|
||||
<div className="w-64 shrink-0 p-2 font-medium text-sm border-r bg-muted/30">
|
||||
Task
|
||||
</div>
|
||||
<div className="flex-1 flex">
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex-1 p-1 text-center text-xs border-r last:border-r-0",
|
||||
isSameDay(day, new Date()) && "bg-primary/10",
|
||||
day.getDay() === 0 || day.getDay() === 6 ? "bg-muted/30" : ""
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{format(day, 'EEE')}</div>
|
||||
<div className="text-muted-foreground">{format(day, 'd')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Rows */}
|
||||
{tasksWithDueDates.map((task) => {
|
||||
const position = getTaskPosition(task);
|
||||
const overdue = isOverdue(task);
|
||||
|
||||
return (
|
||||
<div key={task.id} className="flex border-b hover:bg-muted/20">
|
||||
{/* Task Info */}
|
||||
<div className="w-64 shrink-0 p-2 border-r">
|
||||
<Link
|
||||
to={`/planning/task/${task.id}`}
|
||||
className="flex items-start gap-2 hover:text-primary"
|
||||
>
|
||||
<span className={cn("mt-0.5", typeColors[task.type].replace('bg-', 'text-'))}>
|
||||
{typeIcons[task.type]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{task.type}
|
||||
</Badge>
|
||||
{overdue && (
|
||||
<span className="flex items-center text-destructive text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Overdue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Timeline Bar */}
|
||||
<div className="flex-1 relative h-16 flex items-center">
|
||||
{/* Day grid lines */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex-1 border-r last:border-r-0",
|
||||
isSameDay(day, new Date()) && "bg-primary/5",
|
||||
day.getDay() === 0 || day.getDay() === 6 ? "bg-muted/20" : ""
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task Bar */}
|
||||
{position && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-8 rounded-md flex items-center px-2 text-white text-xs font-medium shadow-sm border-l-4",
|
||||
typeColors[task.type],
|
||||
priorityColors[task.priority],
|
||||
task.status === 'done' && "opacity-60",
|
||||
overdue && "ring-2 ring-destructive ring-offset-1"
|
||||
)}
|
||||
style={{ left: position.left, width: position.width, minWidth: '24px' }}
|
||||
title={`${task.title} - Due: ${task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : 'No date'}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{task.status === 'done' ? '✓' : ''} {task.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t text-xs text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-3 rounded bg-green-500" />
|
||||
<span>Story</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-3 rounded bg-blue-500" />
|
||||
<span>Task</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-3 rounded bg-red-500" />
|
||||
<span>Bug</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-3 rounded bg-primary/10" />
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
114
src/components/planning/SprintDialog.tsx
Normal file
114
src/components/planning/SprintDialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { Sprint } from '@/types/planning';
|
||||
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 { format, addDays } from 'date-fns';
|
||||
|
||||
interface SprintDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sprint?: Sprint | null;
|
||||
onSave: (sprint: Omit<Sprint, 'id'> | Partial<Sprint>) => void;
|
||||
}
|
||||
|
||||
export function SprintDialog({ open, onOpenChange, sprint, onSave }: SprintDialogProps) {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const twoWeeksLater = format(addDays(new Date(), 14), 'yyyy-MM-dd');
|
||||
|
||||
const [name, setName] = useState(sprint?.name ?? '');
|
||||
const [goal, setGoal] = useState(sprint?.goal ?? '');
|
||||
const [startDate, setStartDate] = useState(sprint?.startDate ?? today);
|
||||
const [endDate, setEndDate] = useState(sprint?.endDate ?? twoWeeksLater);
|
||||
|
||||
const handleSave = () => {
|
||||
const sprintData = {
|
||||
name,
|
||||
goal,
|
||||
startDate,
|
||||
endDate,
|
||||
status: sprint?.status ?? 'planning' as const,
|
||||
};
|
||||
|
||||
onSave(sprint ? { ...sprintData, id: sprint.id } : sprintData);
|
||||
onOpenChange(false);
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setGoal('');
|
||||
setStartDate(today);
|
||||
setEndDate(twoWeeksLater);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{sprint ? 'Edit Sprint' : 'Create Sprint'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Sprint Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Sprint 1 - Foundation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="goal">Sprint Goal</Label>
|
||||
<Textarea
|
||||
id="goal"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="What do we want to achieve this sprint?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate">End Date</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
{sprint ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
118
src/components/planning/SprintVelocityChart.tsx
Normal file
118
src/components/planning/SprintVelocityChart.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { usePlanningData } from '@/hooks/usePlanning';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
|
||||
export function SprintVelocityChart() {
|
||||
const { data, isLoading } = usePlanningData();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center h-64">
|
||||
Loading velocity data...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const sprints = data?.sprints || [];
|
||||
const tasks = data?.tasks || [];
|
||||
|
||||
// Calculate velocity for each completed or active sprint
|
||||
const velocityData = sprints
|
||||
.filter(s => s.status === 'completed' || s.status === 'active')
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
||||
.map(sprint => {
|
||||
const sprintTasks = tasks.filter(t => t.sprintId === sprint.id);
|
||||
const planned = sprintTasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||
const completed = sprintTasks
|
||||
.filter(t => t.status === 'done')
|
||||
.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||
|
||||
return {
|
||||
name: sprint.name,
|
||||
planned,
|
||||
completed,
|
||||
status: sprint.status,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate average velocity
|
||||
const completedSprints = velocityData.filter(v => v.status === 'completed');
|
||||
const avgVelocity = completedSprints.length > 0
|
||||
? Math.round(completedSprints.reduce((sum, v) => sum + v.completed, 0) / completedSprints.length)
|
||||
: 0;
|
||||
|
||||
if (velocityData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Sprint Velocity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
No sprint data available. Complete a sprint to see velocity.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Sprint Velocity
|
||||
</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Avg: {avgVelocity} pts/sprint
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={velocityData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-xs fill-muted-foreground"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs fill-muted-foreground"
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ value: 'Story Points', angle: -90, position: 'insideLeft', fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
color: 'hsl(var(--popover-foreground))'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="planned"
|
||||
name="Planned"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name="Completed"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
264
src/components/planning/TaskCard.tsx
Normal file
264
src/components/planning/TaskCard.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Task, TaskType, TaskPriority, TaskStatus } from '@/types/planning';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
CheckSquare,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit,
|
||||
ExternalLink,
|
||||
GripVertical,
|
||||
User,
|
||||
Target,
|
||||
Eye,
|
||||
Clock,
|
||||
Zap,
|
||||
Calendar,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTraceabilityData } from '@/hooks/useTraceabilityData';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onEdit?: (task: Task) => void;
|
||||
onDelete?: (taskId: string) => void;
|
||||
isDragging?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<TaskType, React.ReactNode> = {
|
||||
Story: <BookOpen className="h-3.5 w-3.5" />,
|
||||
Bug: <Bug className="h-3.5 w-3.5" />,
|
||||
Task: <CheckSquare className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
Story: 'bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20',
|
||||
Bug: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
|
||||
Task: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
|
||||
};
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
High: 'bg-red-500/20 text-red-700 dark:text-red-400',
|
||||
Med: 'bg-amber-500/20 text-amber-700 dark:text-amber-400',
|
||||
Low: 'bg-slate-500/20 text-slate-700 dark:text-slate-400',
|
||||
};
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
todo: 'To Do',
|
||||
inprogress: 'In Progress',
|
||||
review: 'Review',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
const statusColors: Record<TaskStatus, string> = {
|
||||
todo: 'bg-slate-500/20 text-slate-700 dark:text-slate-300',
|
||||
inprogress: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
|
||||
review: 'bg-amber-500/20 text-amber-700 dark:text-amber-400',
|
||||
done: 'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
};
|
||||
|
||||
export function TaskCard({ task, onEdit, onDelete, isDragging, compact }: TaskCardProps) {
|
||||
const { groupedByType } = useTraceabilityData();
|
||||
const features = groupedByType['feature'] || [];
|
||||
|
||||
const openProjectUrl = task.openProjectId
|
||||
? `https://openproject.nabd-co.com/projects/asf/work_packages/${task.openProjectId}/activity`
|
||||
: null;
|
||||
|
||||
// Get feature name from traceability data
|
||||
const featureName = task.featureId
|
||||
? features.find(f => f.id === task.featureId)?.subject
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing transition-all duration-200',
|
||||
'hover:shadow-md hover:border-primary/30',
|
||||
isDragging && 'opacity-50 rotate-2 shadow-lg',
|
||||
compact ? 'p-2' : ''
|
||||
)}
|
||||
draggable
|
||||
data-task-id={task.id}
|
||||
>
|
||||
<CardContent className={cn('p-3', compact && 'p-2')}>
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground/50 mt-0.5 shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<Badge variant="outline" className={cn('text-xs', typeColors[task.type])}>
|
||||
{typeIcons[task.type]}
|
||||
<span className="ml-1">{task.type}</span>
|
||||
</Badge>
|
||||
<Badge variant="secondary" className={cn('text-xs', priorityColors[task.priority])}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className={cn('text-xs', statusColors[task.status])}>
|
||||
{statusLabels[task.status]}
|
||||
</Badge>
|
||||
{task.storyPoints && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
{task.storyPoints} pts
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/planning/task/${task.id}`}
|
||||
className="font-medium text-sm leading-tight mb-1 line-clamp-2 hover:text-primary hover:underline block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
|
||||
{!compact && task.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Assignee and Feature info */}
|
||||
{!compact && (task.assignee || task.featureId) && (
|
||||
<div className="flex items-center gap-3 mb-2 text-xs text-muted-foreground flex-wrap">
|
||||
{task.assignee && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
{task.assignee}
|
||||
</span>
|
||||
)}
|
||||
{task.featureId && (
|
||||
<span className="flex items-center gap-1 max-w-[200px] truncate" title={featureName || `Feature #${task.featureId}`}>
|
||||
<Target className="h-3 w-3 shrink-0" />
|
||||
{featureName ? `${featureName.substring(0, 25)}${featureName.length > 25 ? '...' : ''}` : `#${task.featureId}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time tracking and Due date info */}
|
||||
{!compact && (task.estimatedHours || task.loggedHours > 0 || task.dueDate) && (
|
||||
<div className="flex items-center gap-3 mb-2 text-xs text-muted-foreground flex-wrap">
|
||||
{(task.estimatedHours || task.loggedHours > 0) && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{task.loggedHours || 0}h logged
|
||||
{task.estimatedHours && ` / ${task.estimatedHours}h est`}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
new Date(task.dueDate) < new Date() && task.status !== 'done' && "text-destructive"
|
||||
)}>
|
||||
{new Date(task.dueDate) < new Date() && task.status !== 'done' ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Calendar className="h-3 w-3" />
|
||||
)}
|
||||
Due: {new Date(task.dueDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gitea links */}
|
||||
{!compact && (task.giteaBranch || task.giteaPR) && (
|
||||
<div className="flex items-center gap-3 mb-2 text-xs flex-wrap">
|
||||
{task.giteaBranch && (
|
||||
<a
|
||||
href={task.giteaBranch}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
Branch
|
||||
</a>
|
||||
)}
|
||||
{task.giteaPR && (
|
||||
<a
|
||||
href={task.giteaPR}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
Pull Request
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{openProjectUrl && (
|
||||
<a
|
||||
href={openProjectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
#{task.openProjectId}
|
||||
</a>
|
||||
)}
|
||||
{compact && task.assignee && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
{task.assignee}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/planning/task/${task.id}`}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit?.(task)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Quick Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(task.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
146
src/components/planning/TaskComments.tsx
Normal file
146
src/components/planning/TaskComments.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { TaskComment, Attachment } from '@/types/planning';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { planningService } from '@/services/planningService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Trash2, MessageSquare, Send, Paperclip } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { AttachmentUpload, AttachmentList } from './AttachmentUpload';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
interface TaskCommentsProps {
|
||||
taskId: string;
|
||||
comments: TaskComment[];
|
||||
onCommentsChange: () => void;
|
||||
}
|
||||
|
||||
export function TaskComments({ taskId, comments, onCommentsChange }: TaskCommentsProps) {
|
||||
const { user } = useAuth();
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [showAttachments, setShowAttachments] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((!newComment.trim() && attachments.length === 0) || !user) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await planningService.addComment(taskId, user.id, user.username, newComment.trim(), attachments);
|
||||
setNewComment('');
|
||||
setAttachments([]);
|
||||
setShowAttachments(false);
|
||||
onCommentsChange();
|
||||
toast.success('Comment added');
|
||||
} catch (error) {
|
||||
toast.error('Failed to add comment');
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
try {
|
||||
await planningService.deleteComment(commentId);
|
||||
onCommentsChange();
|
||||
toast.success('Comment deleted');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete comment');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<h3 className="font-semibold">Activity & Comments</h3>
|
||||
<span className="text-sm text-muted-foreground">({comments.length})</span>
|
||||
</div>
|
||||
|
||||
{/* Add comment form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button type="submit" disabled={((!newComment.trim() && attachments.length === 0)) || isSubmitting} size="sm">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAttachments(!showAttachments)}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={showAttachments} onOpenChange={setShowAttachments}>
|
||||
<CollapsibleContent>
|
||||
<AttachmentUpload
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
maxFiles={3}
|
||||
maxSizeMB={5}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</form>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="space-y-3">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No comments yet. Be the first to comment!
|
||||
</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
{comment.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{comment.username}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
{user?.id === comment.userId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm mt-1 whitespace-pre-wrap">{comment.content}</p>
|
||||
{comment.attachments && comment.attachments.length > 0 && (
|
||||
<AttachmentList attachments={comment.attachments} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
src/components/planning/TaskDialog.tsx
Normal file
369
src/components/planning/TaskDialog.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Task, TaskType, TaskPriority, TaskStatus, Attachment } from '@/types/planning';
|
||||
import { User } from '@/types/auth';
|
||||
import { authService } from '@/services/authService';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
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 { useTraceabilityData } from '@/hooks/useTraceabilityData';
|
||||
import { AttachmentUpload } from './AttachmentUpload';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronDown, Calendar, GitBranch, GitPullRequest } from 'lucide-react';
|
||||
|
||||
interface TaskDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
task?: Task | null;
|
||||
onSave: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'loggedHours'> | Partial<Task>) => void;
|
||||
sprintId?: string | null;
|
||||
}
|
||||
|
||||
export function TaskDialog({ open, onOpenChange, task, onSave, sprintId }: TaskDialogProps) {
|
||||
const { groupedByType } = useTraceabilityData();
|
||||
const { user } = useAuth();
|
||||
const features = groupedByType['feature'] || [];
|
||||
|
||||
const [title, setTitle] = useState(task?.title ?? '');
|
||||
const [description, setDescription] = useState(task?.description ?? '');
|
||||
const [type, setType] = useState<TaskType>(task?.type ?? 'Task');
|
||||
const [priority, setPriority] = useState<TaskPriority>(task?.priority ?? 'Med');
|
||||
const [status, setStatus] = useState<TaskStatus>(task?.status ?? 'todo');
|
||||
const [assignee, setAssignee] = useState(task?.assignee ?? '');
|
||||
const [reporter, setReporter] = useState(task?.reporter ?? '');
|
||||
const [featureId, setFeatureId] = useState(task?.featureId ?? '');
|
||||
const [openProjectId, setOpenProjectId] = useState(task?.openProjectId?.toString() ?? '');
|
||||
const [storyPoints, setStoryPoints] = useState(task?.storyPoints?.toString() ?? '');
|
||||
const [estimatedHours, setEstimatedHours] = useState(task?.estimatedHours?.toString() ?? '');
|
||||
const [dueDate, setDueDate] = useState(task?.dueDate ?? '');
|
||||
const [giteaBranch, setGiteaBranch] = useState(task?.giteaBranch ?? '');
|
||||
const [giteaPR, setGiteaPR] = useState(task?.giteaPR ?? '');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>(task?.attachments ?? []);
|
||||
const [members, setMembers] = useState<User[]>([]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Fetch approved members for dropdowns
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
authService.getApprovedMembers().then(setMembers).catch(console.error);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset form when task changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(task?.title ?? '');
|
||||
setDescription(task?.description ?? '');
|
||||
setType(task?.type ?? 'Task');
|
||||
setPriority(task?.priority ?? 'Med');
|
||||
setStatus(task?.status ?? 'todo');
|
||||
setAssignee(task?.assignee ?? '');
|
||||
// Auto-set reporter to current user if creating new task
|
||||
setReporter(task?.reporter ?? user?.username ?? '');
|
||||
setFeatureId(task?.featureId ?? '');
|
||||
setOpenProjectId(task?.openProjectId?.toString() ?? '');
|
||||
setStoryPoints(task?.storyPoints?.toString() ?? '');
|
||||
setEstimatedHours(task?.estimatedHours?.toString() ?? '');
|
||||
setDueDate(task?.dueDate ?? '');
|
||||
setGiteaBranch(task?.giteaBranch ?? '');
|
||||
setGiteaPR(task?.giteaPR ?? '');
|
||||
setAttachments(task?.attachments ?? []);
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
}, [task, open, user]);
|
||||
|
||||
const handleSave = () => {
|
||||
const taskData = {
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
priority,
|
||||
status,
|
||||
assignee: assignee === '__unassigned__' ? null : (assignee.trim() || null),
|
||||
reporter: reporter === '__unassigned__' ? null : (reporter.trim() || null),
|
||||
featureId: featureId === '__none__' ? null : featureId || null,
|
||||
sprintId: task?.sprintId ?? sprintId ?? null,
|
||||
openProjectId: openProjectId ? parseInt(openProjectId, 10) : undefined,
|
||||
storyPoints: storyPoints ? parseInt(storyPoints, 10) : null,
|
||||
estimatedHours: estimatedHours ? parseFloat(estimatedHours) : null,
|
||||
dueDate: dueDate || null,
|
||||
giteaBranch: giteaBranch.trim() || null,
|
||||
giteaPR: giteaPR.trim() || null,
|
||||
attachments,
|
||||
};
|
||||
|
||||
onSave(task ? { ...taskData, id: task.id } : taskData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task ? 'Edit Task' : 'Create Task'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task title..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Task description..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as TaskType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Story">Story</SelectItem>
|
||||
<SelectItem value="Bug">Bug</SelectItem>
|
||||
<SelectItem value="Task">Task</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Med">Medium</SelectItem>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as TaskStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="review">Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Assignee</Label>
|
||||
<Select value={assignee || '__unassigned__'} onValueChange={setAssignee}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select assignee..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((member) => (
|
||||
<SelectItem key={member.id} value={member.username}>
|
||||
{member.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Reporter</Label>
|
||||
<Select value={reporter || '__unassigned__'} onValueChange={setReporter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select reporter..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((member) => (
|
||||
<SelectItem key={member.id} value={member.username}>
|
||||
{member.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="storyPoints">Story Points</Label>
|
||||
<Select value={storyPoints || '__none__'} onValueChange={(v) => setStoryPoints(v === '__none__' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select points..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="8">8</SelectItem>
|
||||
<SelectItem value="13">13</SelectItem>
|
||||
<SelectItem value="21">21</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estimatedHours">Estimated Hours</Label>
|
||||
<Input
|
||||
id="estimatedHours"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={estimatedHours}
|
||||
onChange={(e) => setEstimatedHours(e.target.value)}
|
||||
placeholder="e.g., 4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueDate" className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Due Date
|
||||
</Label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gitea Links */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="giteaBranch" className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Gitea Branch URL
|
||||
</Label>
|
||||
<Input
|
||||
id="giteaBranch"
|
||||
type="url"
|
||||
value={giteaBranch}
|
||||
onChange={(e) => setGiteaBranch(e.target.value)}
|
||||
placeholder="https://gitea.example.com/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="giteaPR" className="flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
Gitea PR URL
|
||||
</Label>
|
||||
<Input
|
||||
id="giteaPR"
|
||||
type="url"
|
||||
value={giteaPR}
|
||||
onChange={(e) => setGiteaPR(e.target.value)}
|
||||
placeholder="https://gitea.example.com/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced section */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||
Advanced Options
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Related Feature (optional)</Label>
|
||||
<Select value={featureId || '__none__'} onValueChange={setFeatureId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a feature..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{features.map((feature) => (
|
||||
<SelectItem key={feature.id} value={feature.id}>
|
||||
#{feature.id} - {feature.subject}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Link this task to a feature from the traceability data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openProjectId">OpenProject ID (optional)</Label>
|
||||
<Input
|
||||
id="openProjectId"
|
||||
type="number"
|
||||
value={openProjectId}
|
||||
onChange={(e) => setOpenProjectId(e.target.value)}
|
||||
placeholder="e.g., 42"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Links to OpenProject work package for traceability
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Attachments</Label>
|
||||
<AttachmentUpload
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
maxFiles={5}
|
||||
maxSizeMB={5}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!title.trim()}>
|
||||
{task ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
249
src/components/planning/TaskFilters.tsx
Normal file
249
src/components/planning/TaskFilters.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Task, TaskType, TaskPriority, TaskStatus } from '@/types/planning';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Filter, X, Search } from 'lucide-react';
|
||||
|
||||
interface TaskFiltersProps {
|
||||
tasks: Task[];
|
||||
onFilteredTasksChange: (tasks: Task[]) => void;
|
||||
features?: Array<{ id: string; subject: string }>;
|
||||
}
|
||||
|
||||
export function TaskFilters({ tasks, onFilteredTasksChange, features = [] }: TaskFiltersProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TaskType | 'all'>('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [assigneeFilter, setAssigneeFilter] = useState<string>('all');
|
||||
const [featureFilter, setFeatureFilter] = useState<string>('all');
|
||||
|
||||
// Get unique assignees from tasks
|
||||
const uniqueAssignees = useMemo(() => {
|
||||
const assignees = tasks
|
||||
.map(t => t.assignee)
|
||||
.filter((a): a is string => !!a);
|
||||
return [...new Set(assignees)].sort();
|
||||
}, [tasks]);
|
||||
|
||||
// Get unique features from tasks
|
||||
const uniqueFeatureIds = useMemo(() => {
|
||||
return [...new Set(tasks.map(t => t.featureId).filter((f): f is string => !!f))];
|
||||
}, [tasks]);
|
||||
|
||||
// Apply filters
|
||||
useMemo(() => {
|
||||
let filtered = tasks;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(t =>
|
||||
t.title.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(t => t.type === typeFilter);
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (priorityFilter !== 'all') {
|
||||
filtered = filtered.filter(t => t.priority === priorityFilter);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(t => t.status === statusFilter);
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (assigneeFilter !== 'all') {
|
||||
if (assigneeFilter === '__unassigned__') {
|
||||
filtered = filtered.filter(t => !t.assignee);
|
||||
} else {
|
||||
filtered = filtered.filter(t => t.assignee === assigneeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
// Feature filter
|
||||
if (featureFilter !== 'all') {
|
||||
if (featureFilter === '__none__') {
|
||||
filtered = filtered.filter(t => !t.featureId);
|
||||
} else {
|
||||
filtered = filtered.filter(t => t.featureId === featureFilter);
|
||||
}
|
||||
}
|
||||
|
||||
onFilteredTasksChange(filtered);
|
||||
}, [tasks, searchQuery, typeFilter, priorityFilter, statusFilter, assigneeFilter, featureFilter, onFilteredTasksChange]);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery ||
|
||||
typeFilter !== 'all' ||
|
||||
priorityFilter !== 'all' ||
|
||||
statusFilter !== 'all' ||
|
||||
assigneeFilter !== 'all' ||
|
||||
featureFilter !== 'all';
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setTypeFilter('all');
|
||||
setPriorityFilter('all');
|
||||
setStatusFilter('all');
|
||||
setAssigneeFilter('all');
|
||||
setFeatureFilter('all');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-7">
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search tasks..."
|
||||
className="pl-8 h-8 w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as TaskType | 'all')}>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="Story">Story</SelectItem>
|
||||
<SelectItem value="Bug">Bug</SelectItem>
|
||||
<SelectItem value="Task">Task</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Priority */}
|
||||
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as TaskPriority | 'all')}>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Priorities</SelectItem>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Med">Medium</SelectItem>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | 'all')}>
|
||||
<SelectTrigger className="h-8 w-[130px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="review">Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Assignee */}
|
||||
<Select value={assigneeFilter} onValueChange={setAssigneeFilter}>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Assignee" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Assignees</SelectItem>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{uniqueAssignees.map(assignee => (
|
||||
<SelectItem key={assignee} value={assignee}>
|
||||
{assignee}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Feature */}
|
||||
<Select value={featureFilter} onValueChange={setFeatureFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Feature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Features</SelectItem>
|
||||
<SelectItem value="__none__">No Feature</SelectItem>
|
||||
{uniqueFeatureIds.map(featureId => {
|
||||
const feature = features.find(f => f.id === featureId);
|
||||
return (
|
||||
<SelectItem key={featureId} value={featureId}>
|
||||
#{featureId} {feature?.subject ? `- ${feature.subject.substring(0, 20)}...` : ''}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Active filters:</span>
|
||||
{searchQuery && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Search: "{searchQuery}"
|
||||
</Badge>
|
||||
)}
|
||||
{typeFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Type: {typeFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{priorityFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Priority: {priorityFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{statusFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Status: {statusFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{assigneeFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Assignee: {assigneeFilter === '__unassigned__' ? 'Unassigned' : assigneeFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{featureFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Feature: {featureFilter === '__none__' ? 'None' : `#${featureFilter}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
src/components/planning/TimeTracking.tsx
Normal file
194
src/components/planning/TimeTracking.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState } from 'react';
|
||||
import { TimeLog } from '@/types/planning';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { planningService } from '@/services/planningService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Clock, Plus, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TimeTrackingProps {
|
||||
taskId: string;
|
||||
timeLogs: TimeLog[];
|
||||
estimatedHours: number | null;
|
||||
loggedHours: number;
|
||||
onTimeLogsChange: () => void;
|
||||
}
|
||||
|
||||
export function TimeTracking({
|
||||
taskId,
|
||||
timeLogs,
|
||||
estimatedHours,
|
||||
loggedHours,
|
||||
onTimeLogsChange
|
||||
}: TimeTrackingProps) {
|
||||
const { user } = useAuth();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [hours, setHours] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!hours || !user) return;
|
||||
|
||||
const hoursNum = parseFloat(hours);
|
||||
if (isNaN(hoursNum) || hoursNum <= 0) {
|
||||
toast.error('Please enter a valid number of hours');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await planningService.logTime(taskId, user.id, user.username, hoursNum, description.trim());
|
||||
setHours('');
|
||||
setDescription('');
|
||||
setIsDialogOpen(false);
|
||||
onTimeLogsChange();
|
||||
toast.success('Time logged successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to log time');
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (timeLogId: string) => {
|
||||
try {
|
||||
await planningService.deleteTimeLog(timeLogId);
|
||||
onTimeLogsChange();
|
||||
toast.success('Time log deleted');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete time log');
|
||||
}
|
||||
};
|
||||
|
||||
const progressPercent = estimatedHours
|
||||
? Math.min(100, (loggedHours / estimatedHours) * 100)
|
||||
: 0;
|
||||
|
||||
const isOverEstimate = estimatedHours && loggedHours > estimatedHours;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
<h3 className="font-semibold">Time Tracking</h3>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Log Time
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log Time</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hours">Hours</Label>
|
||||
<Input
|
||||
id="hours"
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0.25"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
placeholder="e.g., 2.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What did you work on?"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
Log Time
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className={isOverEstimate ? 'text-destructive font-medium' : ''}>
|
||||
{loggedHours}h / {estimatedHours ?? '?'}h
|
||||
{isOverEstimate && ' (over estimate)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${isOverEstimate ? 'bg-destructive' : 'bg-primary'}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time logs list */}
|
||||
<div className="space-y-2">
|
||||
{timeLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No time logged yet
|
||||
</p>
|
||||
) : (
|
||||
timeLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-center gap-3 p-2 rounded-lg bg-muted/50">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{log.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{log.username}</span>
|
||||
<span className="text-primary font-medium">{log.hours}h</span>
|
||||
{log.description && (
|
||||
<span className="text-muted-foreground truncate">
|
||||
— {log.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(log.loggedAt), 'MMM d, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
{user?.id === log.userId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(log.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
import { User } from "@/types/auth";
|
||||
import { authService } from "@/services/authService";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; message: string }>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const SSO_API_URL = "https://sso.nabd-co.com/verify";
|
||||
const SSO_API_KEY = "yPkNLCYNm7-UrSZtr_hi-oCx6LZ1DQFAKTGNOoCiMic";
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -31,51 +22,31 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session on mount
|
||||
const storedUser = localStorage.getItem("auth_user");
|
||||
if (storedUser) {
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
localStorage.removeItem("auth_user");
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const response = await fetch(SSO_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
api_key: SSO_API_KEY,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authorized && data.user) {
|
||||
setUser(data.user);
|
||||
localStorage.setItem("auth_user", JSON.stringify(data.user));
|
||||
return { success: true, message: data.message };
|
||||
} else {
|
||||
return { success: false, message: data.message || "Authentication failed" };
|
||||
}
|
||||
const currentUser = await authService.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return { success: false, message: "Connection error. Please try again." };
|
||||
console.error('Failed to get current user:', error);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
useEffect(() => {
|
||||
refreshUser().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; message: string }> => {
|
||||
const result = await authService.login(username, password);
|
||||
if (result.success && result.user) {
|
||||
setUser(result.user);
|
||||
}
|
||||
return { success: result.success, message: result.message };
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
localStorage.removeItem("auth_user");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,8 +55,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === 'admin',
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
45
src/contexts/BudgetContext.tsx
Normal file
45
src/contexts/BudgetContext.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { useBudgetSettings, useUpdateBudgetSettings } from '@/hooks/useBudget';
|
||||
import { BudgetSettings } from '@/types/budget';
|
||||
|
||||
interface BudgetContextType {
|
||||
settings: BudgetSettings | undefined;
|
||||
isLoading: boolean;
|
||||
updateSettings: (updates: Partial<BudgetSettings>) => void;
|
||||
formatCurrency: (amount: number) => string;
|
||||
}
|
||||
|
||||
const BudgetContext = createContext<BudgetContextType | undefined>(undefined);
|
||||
|
||||
export function BudgetProvider({ children }: { children: ReactNode }) {
|
||||
const { data: settings, isLoading } = useBudgetSettings();
|
||||
const updateSettingsMutation = useUpdateBudgetSettings();
|
||||
|
||||
const updateSettings = (updates: Partial<BudgetSettings>) => {
|
||||
updateSettingsMutation.mutate(updates);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number): string => {
|
||||
const currency = settings?.currency || 'USD';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<BudgetContext.Provider value={{ settings, isLoading, updateSettings, formatCurrency }}>
|
||||
{children}
|
||||
</BudgetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBudgetContext() {
|
||||
const context = useContext(BudgetContext);
|
||||
if (!context) {
|
||||
throw new Error('useBudgetContext must be used within a BudgetProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
323
src/hooks/useBudget.ts
Normal file
323
src/hooks/useBudget.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { budgetService } from '@/services/budgetService';
|
||||
import { Transaction, Founder, BudgetSettings, BudgetTarget, RecurringTransaction } from '@/types/budget';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
transactions: ['budget', 'transactions'],
|
||||
founders: ['budget', 'founders'],
|
||||
settings: ['budget', 'settings'],
|
||||
summary: ['budget', 'summary'],
|
||||
monthlyData: ['budget', 'monthly'],
|
||||
targets: ['budget', 'targets'],
|
||||
recurring: ['budget', 'recurring'],
|
||||
all: ['budget'],
|
||||
};
|
||||
|
||||
// ==================== TRANSACTION HOOKS ====================
|
||||
|
||||
export const useTransactions = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.transactions,
|
||||
queryFn: budgetService.getTransactions,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.createTransaction,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.transactions });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.monthlyData });
|
||||
toast({ title: 'Transaction added' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: 'Failed to add transaction', description: error.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<Transaction> }) =>
|
||||
budgetService.updateTransaction(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.transactions });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.monthlyData });
|
||||
toast({ title: 'Transaction updated' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.deleteTransaction,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.transactions });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.monthlyData });
|
||||
toast({ title: 'Transaction deleted' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== FOUNDER HOOKS ====================
|
||||
|
||||
export const useFounders = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.founders,
|
||||
queryFn: budgetService.getFounders,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateFounder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.createFounder,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.founders });
|
||||
toast({ title: 'Founder added' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFounder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<Founder> }) =>
|
||||
budgetService.updateFounder(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.founders });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFounder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.deleteFounder,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.founders });
|
||||
toast({ title: 'Founder removed' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== TARGET HOOKS ====================
|
||||
|
||||
export const useTargets = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.targets,
|
||||
queryFn: budgetService.getTargets,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTarget = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.createTarget,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.targets });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
toast({ title: 'Budget target added' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: 'Failed to add target', description: error.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTarget = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<BudgetTarget> }) =>
|
||||
budgetService.updateTarget(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.targets });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
toast({ title: 'Budget target updated' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTarget = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.deleteTarget,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.targets });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
toast({ title: 'Budget target removed' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== RECURRING TRANSACTION HOOKS ====================
|
||||
|
||||
export const useRecurringTransactions = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.recurring,
|
||||
queryFn: budgetService.getRecurringTransactions,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateRecurringTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.createRecurringTransaction,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.recurring });
|
||||
toast({ title: 'Recurring transaction added' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: 'Failed to add recurring transaction', description: error.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateRecurringTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<RecurringTransaction> }) =>
|
||||
budgetService.updateRecurringTransaction(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.recurring });
|
||||
toast({ title: 'Recurring transaction updated' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteRecurringTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.deleteRecurringTransaction,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.recurring });
|
||||
toast({ title: 'Recurring transaction removed' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useProcessRecurringTransactions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.processRecurringTransactions,
|
||||
onSuccess: (count) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.transactions });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.recurring });
|
||||
if (count > 0) {
|
||||
toast({ title: `Processed ${count} recurring transaction${count > 1 ? 's' : ''}` });
|
||||
} else {
|
||||
toast({ title: 'No recurring transactions due' });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== SETTINGS HOOKS ====================
|
||||
|
||||
export const useBudgetSettings = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.settings,
|
||||
queryFn: budgetService.getSettings,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateBudgetSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.updateSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.settings });
|
||||
toast({ title: 'Settings updated' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== ANALYTICS HOOKS ====================
|
||||
|
||||
export const useBudgetSummary = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.summary,
|
||||
queryFn: budgetService.getBudgetSummary,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMonthlyData = (months: number = 12) => {
|
||||
return useQuery({
|
||||
queryKey: [...QUERY_KEYS.monthlyData, months],
|
||||
queryFn: () => budgetService.getMonthlyData(months),
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== IMPORT/EXPORT HOOKS ====================
|
||||
|
||||
export const useExportTransactions = () => {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const csv = await budgetService.exportTransactionsCSV();
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `transactions_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Transactions exported' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportTransactions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: budgetService.importTransactionsCSV,
|
||||
onSuccess: (count) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.transactions });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.summary });
|
||||
toast({ title: `Imported ${count} transactions` });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: 'Import failed', description: error.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
};
|
||||
244
src/hooks/usePlanning.ts
Normal file
244
src/hooks/usePlanning.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { planningService } from '@/services/planningService';
|
||||
import { Sprint, Task, TaskStatus } from '@/types/planning';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const QUERY_KEYS = {
|
||||
sprints: ['planning', 'sprints'],
|
||||
tasks: ['planning', 'tasks'],
|
||||
task: (id: string) => ['planning', 'task', id],
|
||||
backlog: ['planning', 'backlog'],
|
||||
sprintTasks: (sprintId: string) => ['planning', 'sprint-tasks', sprintId],
|
||||
allData: ['planning', 'all'],
|
||||
};
|
||||
|
||||
// ==================== SPRINT HOOKS ====================
|
||||
|
||||
export const useSprints = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.sprints,
|
||||
queryFn: planningService.getSprints,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateSprint = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: planningService.createSprint,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sprints });
|
||||
toast({ title: 'Sprint created successfully' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: 'Failed to create sprint', description: error.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSprint = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<Sprint> }) =>
|
||||
planningService.updateSprint(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sprints });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSprint = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: planningService.deleteSprint,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sprints });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
toast({ title: 'Sprint deleted' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useStartSprint = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: planningService.startSprint,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sprints });
|
||||
toast({ title: 'Sprint started!' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({ title: 'Cannot start sprint', description: error.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCompleteSprint = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: planningService.completeSprint,
|
||||
onSuccess: ({ returnedTasks }) => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sprints });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
if (returnedTasks.length > 0) {
|
||||
toast({
|
||||
title: 'Sprint completed',
|
||||
description: `${returnedTasks.length} unfinished task(s) moved to backlog`
|
||||
});
|
||||
} else {
|
||||
toast({ title: 'Sprint completed successfully!' });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== TASK HOOKS ====================
|
||||
|
||||
export const useTasks = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.tasks,
|
||||
queryFn: planningService.getTasks,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTask = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.task(id),
|
||||
queryFn: () => planningService.getTask(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBacklogTasks = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.backlog,
|
||||
queryFn: planningService.getBacklogTasks,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSprintTasks = (sprintId: string) => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.sprintTasks(sprintId),
|
||||
queryFn: () => planningService.getSprintTasks(sprintId),
|
||||
enabled: !!sprintId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTask = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: planningService.createTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.backlog });
|
||||
toast({ title: 'Task created' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTask = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<Task> }) =>
|
||||
planningService.updateTask(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.backlog });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTask = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: planningService.deleteTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.backlog });
|
||||
toast({ title: 'Task deleted' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMoveTask = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, sprintId }: { taskId: string; sprintId: string | null }) =>
|
||||
planningService.moveTaskToSprint(taskId, sprintId),
|
||||
onMutate: async ({ taskId, sprintId }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(QUERY_KEYS.tasks);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<Task[]>(QUERY_KEYS.tasks, (old) =>
|
||||
old?.map(task => task.id === taskId ? { ...task, sprintId } : task) ?? []
|
||||
);
|
||||
|
||||
return { previousTasks };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousTasks) {
|
||||
queryClient.setQueryData(QUERY_KEYS.tasks, context.previousTasks);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.backlog });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTaskStatus = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ taskId, status }: { taskId: string; status: TaskStatus }) =>
|
||||
planningService.updateTaskStatus(taskId, status),
|
||||
onMutate: async ({ taskId, status }) => {
|
||||
await queryClient.cancelQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(QUERY_KEYS.tasks);
|
||||
|
||||
queryClient.setQueryData<Task[]>(QUERY_KEYS.tasks, (old) =>
|
||||
old?.map(task => task.id === taskId ? { ...task, status } : task) ?? []
|
||||
);
|
||||
|
||||
return { previousTasks };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previousTasks) {
|
||||
queryClient.setQueryData(QUERY_KEYS.tasks, context.previousTasks);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.tasks });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== COMBINED DATA ====================
|
||||
|
||||
export const usePlanningData = () => {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.allData,
|
||||
queryFn: planningService.getAllData,
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { WorkPackage, TraceabilityData, WorkPackageType } from '@/types/traceability';
|
||||
|
||||
const STORAGE_KEY = 'traceability_data';
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export function useTraceabilityData() {
|
||||
const [data, setData] = useState<TraceabilityData | null>(null);
|
||||
@@ -235,12 +236,148 @@ export function useTraceabilityData() {
|
||||
}
|
||||
}, [parseCSV]);
|
||||
|
||||
// Sync from server (triggers Python script) then reload
|
||||
const syncFromServer = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const newLogs: string[] = [];
|
||||
|
||||
const apiBase = API_URL || '';
|
||||
const syncUrl = apiBase ? `${apiBase}/api/traceability/sync` : '/api/traceability/sync';
|
||||
const fetchUrl = apiBase ? `${apiBase}/api/traceability` : '/api/traceability';
|
||||
|
||||
newLogs.push(`[Sync] Starting sync from OpenProject...`);
|
||||
newLogs.push(`[Sync] API URL: ${syncUrl}`);
|
||||
|
||||
try {
|
||||
// Step 1: Trigger the sync (runs Python script)
|
||||
newLogs.push(`[Sync] Triggering Python script...`);
|
||||
setParseLog([...newLogs]);
|
||||
|
||||
const syncResponse = await fetch(syncUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!syncResponse.ok) {
|
||||
const errorData = await syncResponse.json().catch(() => ({ error: `HTTP ${syncResponse.status}` }));
|
||||
|
||||
// If API is not available, fall back to CSV reload
|
||||
if (syncResponse.status === 404) {
|
||||
newLogs.push(`[Sync] ⚠️ Sync API not available (running in dev mode?)`);
|
||||
newLogs.push(`[Sync] Falling back to CSV reload...`);
|
||||
setParseLog([...newLogs]);
|
||||
|
||||
// Fall back to reloading from CSV
|
||||
const cacheBuster = `?t=${Date.now()}`;
|
||||
const response = await fetch(`/data/traceability_export.csv${cacheBuster}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load CSV: ${response.status}`);
|
||||
}
|
||||
|
||||
const csvText = await response.text();
|
||||
newLogs.push(`[Loader] Received ${csvText.length} bytes from static CSV`);
|
||||
|
||||
const { workPackages, logs } = parseCSV(csvText);
|
||||
newLogs.push(...logs);
|
||||
|
||||
const now = new Date();
|
||||
setData({ lastUpdated: now, workPackages });
|
||||
setLastUpdated(now);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
lastUpdated: now.toISOString(),
|
||||
workPackages
|
||||
}));
|
||||
|
||||
newLogs.push(`[Sync] ✅ Loaded ${workPackages.length} work packages from static CSV`);
|
||||
setParseLog(newLogs);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || errorData.details || `Sync failed: ${syncResponse.status}`);
|
||||
}
|
||||
|
||||
const syncResult = await syncResponse.json();
|
||||
newLogs.push(`[Sync] ✅ ${syncResult.message}`);
|
||||
|
||||
if (syncResult.stdout) {
|
||||
syncResult.stdout.split('\n').filter(Boolean).forEach((line: string) => {
|
||||
newLogs.push(`[Python] ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Fetch the updated data
|
||||
newLogs.push(`[Sync] Fetching updated data...`);
|
||||
setParseLog([...newLogs]);
|
||||
|
||||
const response = await fetch(fetchUrl, {
|
||||
headers: { 'Accept': 'text/csv, application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch data: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('text/csv')) {
|
||||
const csvText = await response.text();
|
||||
newLogs.push(`[Sync] Received ${csvText.length} bytes CSV`);
|
||||
|
||||
const { workPackages, logs } = parseCSV(csvText);
|
||||
newLogs.push(...logs);
|
||||
|
||||
const now = new Date();
|
||||
setData({ lastUpdated: now, workPackages });
|
||||
setLastUpdated(now);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
lastUpdated: now.toISOString(),
|
||||
workPackages
|
||||
}));
|
||||
|
||||
newLogs.push(`[Sync] ✅ Loaded ${workPackages.length} work packages`);
|
||||
} else {
|
||||
const jsonData = await response.json();
|
||||
const workPackages = jsonData.workPackages || jsonData;
|
||||
|
||||
const now = new Date();
|
||||
setData({ lastUpdated: now, workPackages });
|
||||
setLastUpdated(now);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
lastUpdated: now.toISOString(),
|
||||
workPackages
|
||||
}));
|
||||
|
||||
newLogs.push(`[Sync] ✅ Loaded ${workPackages.length} work packages`);
|
||||
}
|
||||
|
||||
setParseLog(newLogs);
|
||||
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
newLogs.push(`[Sync] ❌ Error: ${errorMsg}`);
|
||||
setParseLog(newLogs);
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [parseCSV]);
|
||||
|
||||
// Main refresh function - tries sync first, falls back to CSV
|
||||
const refresh = useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
// In production with API, use sync. Otherwise reload from CSV.
|
||||
if (API_URL) {
|
||||
syncFromServer();
|
||||
} else {
|
||||
reloadFromCSV();
|
||||
}
|
||||
}, [syncFromServer, reloadFromCSV]);
|
||||
|
||||
// Update data and persist
|
||||
const updateData = useCallback((newData: TraceabilityData) => {
|
||||
const updateData = useCallback((newData: TraceabilityData, additionalLogs?: string[]) => {
|
||||
setData(newData);
|
||||
setLastUpdated(newData.lastUpdated);
|
||||
|
||||
@@ -250,7 +387,12 @@ export function useTraceabilityData() {
|
||||
workPackages: newData.workPackages
|
||||
}));
|
||||
|
||||
setParseLog(prev => [...prev, `[Update] Data updated with ${newData.workPackages.length} work packages`]);
|
||||
const logEntry = `[Update] Data updated with ${newData.workPackages.length} work packages`;
|
||||
if (additionalLogs && additionalLogs.length > 0) {
|
||||
setParseLog([...additionalLogs, logEntry]);
|
||||
} else {
|
||||
setParseLog(prev => [...prev, logEntry]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -278,6 +420,7 @@ export function useTraceabilityData() {
|
||||
lastUpdated,
|
||||
refresh,
|
||||
reloadFromCSV,
|
||||
syncFromServer,
|
||||
groupedByType,
|
||||
typeCounts,
|
||||
parseLog,
|
||||
|
||||
125
src/lib/api.ts
Normal file
125
src/lib/api.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// API client for the data service
|
||||
// In development, falls back to localStorage if API is unavailable
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
const IS_PRODUCTION = import.meta.env.PROD;
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private useLocalStorage: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = API_URL;
|
||||
// Check if we should use localStorage (development without API)
|
||||
if (!IS_PRODUCTION && !API_URL) {
|
||||
this.useLocalStorage = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkApiAvailability(): Promise<boolean> {
|
||||
if (!this.baseUrl) return false;
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (IS_PRODUCTION) {
|
||||
// In production, API should always be available
|
||||
const available = await this.checkApiAvailability();
|
||||
if (!available) {
|
||||
console.warn('API not available, using localStorage fallback');
|
||||
this.useLocalStorage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isUsingLocalStorage(): boolean {
|
||||
return this.useLocalStorage;
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string, localStorageKey?: string): Promise<T | null> {
|
||||
if (this.useLocalStorage && localStorageKey) {
|
||||
const stored = localStorage.getItem(localStorageKey);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API GET error for ${endpoint}:`, error);
|
||||
// Fallback to localStorage
|
||||
if (localStorageKey) {
|
||||
const stored = localStorage.getItem(localStorageKey);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async post<T, R = T>(endpoint: string, data: T, localStorageKey?: string): Promise<R> {
|
||||
if (this.useLocalStorage && localStorageKey) {
|
||||
// For localStorage, we handle this at the service level
|
||||
throw new Error('POST not supported with localStorage - handle at service level');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async put<T, R = T>(endpoint: string, data: T): Promise<R> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async delete(endpoint: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
// Initialize API client
|
||||
apiClient.initialize().catch(console.error);
|
||||
537
src/pages/AdminPage.tsx
Normal file
537
src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { authService } from "@/services/authService";
|
||||
import { User, UserRole } from "@/types/auth";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
UserPlus,
|
||||
Check,
|
||||
X,
|
||||
Trash2,
|
||||
Edit,
|
||||
Users,
|
||||
Clock,
|
||||
Shield,
|
||||
Loader2,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { isAdmin, isLoading: authLoading, user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isResetPasswordDialogOpen, setIsResetPasswordDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
// Form states
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState<UserRole>("member");
|
||||
const [editUsername, setEditUsername] = useState("");
|
||||
const [editEmail, setEditEmail] = useState("");
|
||||
const [editRole, setEditRole] = useState<UserRole>("member");
|
||||
const [resetPasswordValue, setResetPasswordValue] = useState("");
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [allUsers, pending] = await Promise.all([
|
||||
authService.getAllUsers(),
|
||||
authService.getPendingUsers(),
|
||||
]);
|
||||
setUsers(allUsers.filter(u => u.status === 'approved'));
|
||||
setPendingUsers(pending);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error('Failed to load users');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleApprove = async (userId: string) => {
|
||||
try {
|
||||
await authService.approveUser(userId);
|
||||
toast.success('User approved successfully');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to approve user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (userId: string) => {
|
||||
try {
|
||||
await authService.rejectUser(userId);
|
||||
toast.success('User rejected');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to reject user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await authService.addUser(newUsername, newEmail, newPassword, newRole, currentUser?.username);
|
||||
toast.success('User added successfully');
|
||||
setIsAddDialogOpen(false);
|
||||
setNewUsername("");
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setNewRole("member");
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to add user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUser) return;
|
||||
try {
|
||||
await authService.updateUser(selectedUser.id, {
|
||||
username: editUsername,
|
||||
email: editEmail,
|
||||
role: editRole,
|
||||
});
|
||||
toast.success('User updated successfully');
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
try {
|
||||
await authService.deleteUser(userId);
|
||||
toast.success('User deleted');
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUser) return;
|
||||
try {
|
||||
await authService.resetPassword(selectedUser.id, resetPasswordValue);
|
||||
toast.success('Password reset successfully');
|
||||
setIsResetPasswordDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
setResetPasswordValue("");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reset password');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setEditUsername(user.username);
|
||||
setEditEmail(user.email);
|
||||
setEditRole(user.role);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const openResetPasswordDialog = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setResetPasswordValue("");
|
||||
setIsResetPasswordDialogOpen(true);
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: UserRole) => {
|
||||
return role === 'admin' ? (
|
||||
<Badge variant="default" className="bg-primary">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Member
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-muted-foreground">Manage users and approvals</p>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new user account. They will receive an email notification.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddUser} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-username">Username</Label>
|
||||
<Input
|
||||
id="add-username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-email">Email</Label>
|
||||
<Input
|
||||
id="add-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-password">Password</Label>
|
||||
<Input
|
||||
id="add-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-role">Role</Label>
|
||||
<Select value={newRole} onValueChange={(v: UserRole) => setNewRole(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Add User</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="pending" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending" className="gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Pending Approvals
|
||||
{pendingUsers.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-1">
|
||||
{pendingUsers.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="members" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pending Registrations</CardTitle>
|
||||
<CardDescription>
|
||||
Review and approve new user registration requests
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : pendingUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No pending registrations
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendingUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{format(new Date(user.createdAt), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleApprove(user.id)}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleReject(user.id)}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<CardDescription>
|
||||
Manage existing users and their roles
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{getRoleBadge(user.role)}</TableCell>
|
||||
<TableCell>{format(new Date(user.createdAt), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openResetPasswordDialog(user)}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete User</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {user.username}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update user information
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleEditUser} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-username">Username</Label>
|
||||
<Input
|
||||
id="edit-username"
|
||||
value={editUsername}
|
||||
onChange={(e) => setEditUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-email">Email</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-role">Role</Label>
|
||||
<Select value={editRole} onValueChange={(v: UserRole) => setEditRole(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset Password Dialog */}
|
||||
<Dialog open={isResetPasswordDialogOpen} onOpenChange={setIsResetPasswordDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set a new password for {selectedUser?.username}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reset-password">New Password</Label>
|
||||
<Input
|
||||
id="reset-password"
|
||||
type="password"
|
||||
value={resetPasswordValue}
|
||||
onChange={(e) => setResetPasswordValue(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Reset Password</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
114
src/pages/BudgetPage.tsx
Normal file
114
src/pages/BudgetPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { BudgetProvider } from '@/contexts/BudgetContext';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BudgetSummaryCards } from '@/components/budget/BudgetSummaryCards';
|
||||
import { ExpenseBreakdownChart } from '@/components/budget/ExpenseBreakdownChart';
|
||||
import { TransactionManager } from '@/components/budget/TransactionManager';
|
||||
import { EquityManager } from '@/components/budget/EquityManager';
|
||||
import { BudgetSettingsPanel } from '@/components/budget/BudgetSettingsPanel';
|
||||
import { BudgetTargetsManager } from '@/components/budget/BudgetTargetsManager';
|
||||
import { RecurringTransactionManager } from '@/components/budget/RecurringTransactionManager';
|
||||
import { FinancialProjections } from '@/components/budget/FinancialProjections';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Receipt,
|
||||
Users,
|
||||
Wallet,
|
||||
Settings,
|
||||
Target,
|
||||
Repeat,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
function BudgetContent() {
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||
<Wallet className="h-8 w-8" />
|
||||
Budget Control
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track transactions, monitor runway, and manage equity
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSettingsOpen(true)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="flex-wrap h-auto">
|
||||
<TabsTrigger value="dashboard" className="flex items-center gap-2">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transactions" className="flex items-center gap-2">
|
||||
<Receipt className="h-4 w-4" />
|
||||
Transactions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recurring" className="flex items-center gap-2">
|
||||
<Repeat className="h-4 w-4" />
|
||||
Recurring
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="projections" className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Projections
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="targets" className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Targets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="equity" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Equity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="mt-6 space-y-6">
|
||||
<BudgetSummaryCards />
|
||||
<ExpenseBreakdownChart />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transactions" className="mt-6">
|
||||
<TransactionManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recurring" className="mt-6">
|
||||
<RecurringTransactionManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projections" className="mt-6">
|
||||
<FinancialProjections />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="targets" className="mt-6">
|
||||
<BudgetTargetsManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="equity" className="mt-6">
|
||||
<EquityManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<BudgetSettingsPanel open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BudgetPage() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<BudgetProvider>
|
||||
<BudgetContent />
|
||||
</BudgetProvider>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -49,8 +49,18 @@ const typeColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data, loading, lastUpdated, refresh, reloadFromCSV, typeCounts, groupedByType, parseLog, setData } =
|
||||
useTraceabilityData();
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
lastUpdated,
|
||||
refresh,
|
||||
reloadFromCSV,
|
||||
syncFromServer,
|
||||
typeCounts,
|
||||
groupedByType,
|
||||
parseLog,
|
||||
setData
|
||||
} = useTraceabilityData();
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
@@ -157,7 +167,18 @@ export default function Dashboard() {
|
||||
Update Data
|
||||
</Button>
|
||||
<Button variant="outline" onClick={reloadFromCSV} disabled={loading}>
|
||||
Reload from CSV
|
||||
{loading ? (
|
||||
<><RefreshCw className="h-4 w-4 mr-2 animate-spin" /> Loading...</>
|
||||
) : (
|
||||
<>Reload from Static CSV</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={syncFromServer} disabled={loading}>
|
||||
{loading ? (
|
||||
<><RefreshCw className="h-4 w-4 mr-2 animate-spin" /> Syncing...</>
|
||||
) : (
|
||||
<><RefreshCw className="h-4 w-4 mr-2" /> Sync from OpenProject</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -111,15 +111,13 @@ export default function LoginPage() {
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Need access?{" "}
|
||||
<a
|
||||
href="https://sso.nabd-co.com/register.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
Request access from administrator
|
||||
</a>
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
64
src/pages/PlanningPage.tsx
Normal file
64
src/pages/PlanningPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { BacklogView } from '@/components/planning/BacklogView';
|
||||
import { BoardView } from '@/components/planning/BoardView';
|
||||
import { GanttView } from '@/components/planning/GanttView';
|
||||
import { SprintVelocityChart } from '@/components/planning/SprintVelocityChart';
|
||||
import { LayoutGrid, List, GanttChart, TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function PlanningPage() {
|
||||
const [activeTab, setActiveTab] = useState('backlog');
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Planning</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your sprints and track work progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="backlog" className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
Backlog
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="board" className="flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Board
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gantt" className="flex items-center gap-2">
|
||||
<GanttChart className="h-4 w-4" />
|
||||
Gantt
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="velocity" className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Velocity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="backlog" className="mt-6">
|
||||
<BacklogView />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="board" className="mt-6">
|
||||
<BoardView />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gantt" className="mt-6">
|
||||
<GanttView />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="velocity" className="mt-6">
|
||||
<SprintVelocityChart />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
201
src/pages/RegisterPage.tsx
Normal file
201
src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { authService } from "@/services/authService";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, UserPlus, AlertCircle, CheckCircle } from "lucide-react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
// Validation
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.includes("@")) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await authService.register(username, email, password);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(result.message);
|
||||
// Clear form
|
||||
setUsername("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Registration failed. Please try again.");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img
|
||||
src="/images/nabd-logo.png"
|
||||
alt="NABD Solutions"
|
||||
className="h-16"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-foreground">Traceability Dashboard</h1>
|
||||
<p className="text-muted-foreground">ASF Sensor Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Card */}
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Create an account</CardTitle>
|
||||
<CardDescription>
|
||||
Register to request access to the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="border-green-500 bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>{success}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Choose a username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading || !!success}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading || !!success}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading || !!success}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading || !!success}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!success && (
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Register
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
788
src/pages/TaskDetailPage.tsx
Normal file
788
src/pages/TaskDetailPage.tsx
Normal file
@@ -0,0 +1,788 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useTask, useUpdateTask, useDeleteTask, useSprints } from '@/hooks/usePlanning';
|
||||
import { useTraceabilityData } from '@/hooks/useTraceabilityData';
|
||||
import { authService } from '@/services/authService';
|
||||
import { planningService } from '@/services/planningService';
|
||||
import { TaskType, TaskPriority, TaskStatus, TaskComment, TimeLog, Attachment } from '@/types/planning';
|
||||
import { User } from '@/types/auth';
|
||||
import { TaskComments } from '@/components/planning/TaskComments';
|
||||
import { TimeTracking } from '@/components/planning/TimeTracking';
|
||||
import { AttachmentUpload, AttachmentList } from '@/components/planning/AttachmentUpload';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
User as UserIcon,
|
||||
Calendar,
|
||||
Target,
|
||||
BookOpen,
|
||||
Bug,
|
||||
CheckSquare,
|
||||
Copy,
|
||||
Clock,
|
||||
Zap,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const typeIcons: Record<TaskType, React.ReactNode> = {
|
||||
Story: <BookOpen className="h-5 w-5" />,
|
||||
Bug: <Bug className="h-5 w-5" />,
|
||||
Task: <CheckSquare className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
Story: 'bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20',
|
||||
Bug: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
|
||||
Task: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
|
||||
};
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
High: 'bg-red-500/20 text-red-700 dark:text-red-400',
|
||||
Med: 'bg-amber-500/20 text-amber-700 dark:text-amber-400',
|
||||
Low: 'bg-slate-500/20 text-slate-700 dark:text-slate-400',
|
||||
};
|
||||
|
||||
const statusColors: Record<TaskStatus, string> = {
|
||||
todo: 'bg-slate-500/20 text-slate-700 dark:text-slate-300',
|
||||
inprogress: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
|
||||
review: 'bg-amber-500/20 text-amber-700 dark:text-amber-400',
|
||||
done: 'bg-green-500/20 text-green-700 dark:text-green-400',
|
||||
};
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: task, isLoading, refetch } = useTask(taskId || '');
|
||||
const { data: sprints = [] } = useSprints();
|
||||
const { groupedByType } = useTraceabilityData();
|
||||
const features = groupedByType['feature'] || [];
|
||||
|
||||
const updateTask = useUpdateTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState<TaskType>('Task');
|
||||
const [priority, setPriority] = useState<TaskPriority>('Med');
|
||||
const [status, setStatus] = useState<TaskStatus>('todo');
|
||||
const [assignee, setAssignee] = useState('');
|
||||
const [reporter, setReporter] = useState('');
|
||||
const [featureId, setFeatureId] = useState('');
|
||||
const [sprintId, setSprintId] = useState('');
|
||||
const [openProjectId, setOpenProjectId] = useState('');
|
||||
const [storyPoints, setStoryPoints] = useState('');
|
||||
const [estimatedHours, setEstimatedHours] = useState('');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [giteaBranch, setGiteaBranch] = useState('');
|
||||
const [giteaPR, setGiteaPR] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Members for dropdowns
|
||||
const [members, setMembers] = useState<User[]>([]);
|
||||
|
||||
// Comments and time logs
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [timeLogs, setTimeLogs] = useState<TimeLog[]>([]);
|
||||
|
||||
// Load members
|
||||
useEffect(() => {
|
||||
authService.getApprovedMembers().then(setMembers).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load comments and time logs
|
||||
const loadCommentsAndLogs = async () => {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
const [fetchedComments, fetchedLogs] = await Promise.all([
|
||||
planningService.getTaskComments(taskId),
|
||||
planningService.getTaskTimeLogs(taskId),
|
||||
]);
|
||||
setComments(fetchedComments);
|
||||
setTimeLogs(fetchedLogs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments/logs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCommentsAndLogs();
|
||||
}, [taskId]);
|
||||
|
||||
// Update form when task changes
|
||||
useEffect(() => {
|
||||
if (task && !isEditing) {
|
||||
setTitle(task.title);
|
||||
setDescription(task.description);
|
||||
setType(task.type);
|
||||
setPriority(task.priority);
|
||||
setStatus(task.status);
|
||||
setAssignee(task.assignee || '');
|
||||
setReporter(task.reporter || '');
|
||||
setFeatureId(task.featureId || '');
|
||||
setSprintId(task.sprintId || '');
|
||||
setOpenProjectId(task.openProjectId?.toString() || '');
|
||||
setStoryPoints(task.storyPoints?.toString() || '');
|
||||
setEstimatedHours(task.estimatedHours?.toString() || '');
|
||||
setDueDate(task.dueDate || '');
|
||||
setGiteaBranch(task.giteaBranch || '');
|
||||
setGiteaPR(task.giteaPR || '');
|
||||
setAttachments(task.attachments || []);
|
||||
}
|
||||
}, [task, isEditing]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!taskId) return;
|
||||
|
||||
updateTask.mutate({
|
||||
id: taskId,
|
||||
updates: {
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
priority,
|
||||
status,
|
||||
assignee: assignee === '__unassigned__' ? null : (assignee.trim() || null),
|
||||
reporter: reporter === '__unassigned__' ? null : (reporter.trim() || null),
|
||||
featureId: featureId === '__none__' ? null : featureId || null,
|
||||
sprintId: sprintId === '__backlog__' ? null : sprintId || null,
|
||||
openProjectId: openProjectId ? parseInt(openProjectId, 10) : undefined,
|
||||
storyPoints: storyPoints ? parseInt(storyPoints, 10) : null,
|
||||
estimatedHours: estimatedHours ? parseFloat(estimatedHours) : null,
|
||||
dueDate: dueDate || null,
|
||||
giteaBranch: giteaBranch.trim() || null,
|
||||
giteaPR: giteaPR.trim() || null,
|
||||
attachments,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Task updated successfully' });
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!taskId) return;
|
||||
|
||||
deleteTask.mutate(taskId, {
|
||||
onSuccess: () => {
|
||||
navigate('/planning');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
const url = window.location.href;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast({ title: 'Link copied to clipboard' });
|
||||
};
|
||||
|
||||
const handleTimeLogsChange = () => {
|
||||
loadCommentsAndLogs();
|
||||
refetch(); // Refresh task to get updated loggedHours
|
||||
};
|
||||
|
||||
const openProjectUrl = task?.openProjectId
|
||||
? `https://openproject.nabd-co.com/projects/asf/work_packages/${task.openProjectId}/activity`
|
||||
: null;
|
||||
|
||||
const currentSprint = sprints.find(s => s.id === task?.sprintId);
|
||||
const relatedFeature = features.find(f => f.id === task?.featureId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex items-center justify-center h-64">Loading task...</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<p className="text-muted-foreground">Task not found</p>
|
||||
<Button variant="outline" onClick={() => navigate('/planning')}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Planning
|
||||
</Button>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/planning')}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={typeColors[task.type]}>
|
||||
{typeIcons[task.type]}
|
||||
<span className="ml-1">{task.type}</span>
|
||||
</Badge>
|
||||
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
|
||||
<Badge className={statusColors[task.status]}>
|
||||
{status === 'todo' ? 'To Do' : status === 'inprogress' ? 'In Progress' : status === 'review' ? 'Review' : 'Done'}
|
||||
</Badge>
|
||||
{task.storyPoints && (
|
||||
<Badge variant="outline">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
{task.storyPoints} pts
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={copyLink}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Link
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={updateTask.isPending}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setIsEditing(true)}>
|
||||
Edit Task
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
) : (
|
||||
task.title
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground mb-2 block">
|
||||
Description
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{task.description || 'No description provided.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{openProjectUrl && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground mb-2 block">
|
||||
OpenProject Link
|
||||
</Label>
|
||||
<a
|
||||
href={openProjectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Work Package #{task.openProjectId}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{relatedFeature && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground mb-2 block">
|
||||
Related Feature
|
||||
</Label>
|
||||
<Link
|
||||
to={`/alm/feature`}
|
||||
className="text-primary hover:underline flex items-center gap-2"
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
#{relatedFeature.id} - {relatedFeature.subject}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Gitea Links */}
|
||||
{(task.giteaBranch || task.giteaPR || isEditing) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-muted-foreground mb-2 block">
|
||||
Gitea Links
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1 mb-1">
|
||||
<GitBranch className="h-3 w-3" /> Branch URL
|
||||
</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={giteaBranch}
|
||||
onChange={(e) => setGiteaBranch(e.target.value)}
|
||||
placeholder="https://gitea.example.com/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1 mb-1">
|
||||
<GitPullRequest className="h-3 w-3" /> Pull Request URL
|
||||
</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={giteaPR}
|
||||
onChange={(e) => setGiteaPR(e.target.value)}
|
||||
placeholder="https://gitea.example.com/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{task.giteaBranch && (
|
||||
<a
|
||||
href={task.giteaBranch}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Branch
|
||||
</a>
|
||||
)}
|
||||
{task.giteaPR && (
|
||||
<a
|
||||
href={task.giteaPR}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-2"
|
||||
>
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
Pull Request
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{(task.attachments?.length > 0 || isEditing) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground mb-2 block">
|
||||
Attachments
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<AttachmentUpload
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
maxFiles={10}
|
||||
maxSizeMB={10}
|
||||
/>
|
||||
) : (
|
||||
<AttachmentList attachments={task.attachments || []} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time Tracking */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<TimeTracking
|
||||
taskId={taskId!}
|
||||
timeLogs={timeLogs}
|
||||
estimatedHours={task.estimatedHours}
|
||||
loggedHours={task.loggedHours}
|
||||
onTimeLogsChange={handleTimeLogsChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comments */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<TaskComments
|
||||
taskId={taskId!}
|
||||
comments={comments}
|
||||
onCommentsChange={loadCommentsAndLogs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timestamps */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Created: {format(new Date(task.createdAt), 'MMM d, yyyy HH:mm')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Updated: {format(new Date(task.updatedAt), 'MMM d, yyyy HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">Status</Label>
|
||||
{isEditing ? (
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as TaskStatus)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inprogress">In Progress</SelectItem>
|
||||
<SelectItem value="review">Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1 capitalize">{task.status === 'inprogress' ? 'In Progress' : task.status}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">Type</Label>
|
||||
{isEditing ? (
|
||||
<Select value={type} onValueChange={(v) => setType(v as TaskType)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Story">Story</SelectItem>
|
||||
<SelectItem value="Bug">Bug</SelectItem>
|
||||
<SelectItem value="Task">Task</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">{task.type}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">Priority</Label>
|
||||
{isEditing ? (
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Med">Medium</SelectItem>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">{task.priority}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Story Points */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" /> Story Points
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Select value={storyPoints || '__none__'} onValueChange={(v) => setStoryPoints(v === '__none__' ? '' : v)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="8">8</SelectItem>
|
||||
<SelectItem value="13">13</SelectItem>
|
||||
<SelectItem value="21">21</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">{task.storyPoints || 'None'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Estimated Hours */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> Estimated Hours
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={estimatedHours}
|
||||
onChange={(e) => setEstimatedHours(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="e.g., 4"
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-1">{task.estimatedHours ? `${task.estimatedHours}h` : 'Not set'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" /> Due Date
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className={`mt-1 flex items-center gap-1 ${task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done' ? 'text-destructive' : ''}`}>
|
||||
{task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done' && (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : 'Not set'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4" /> Assignee
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Select value={assignee || '__unassigned__'} onValueChange={setAssignee}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((member) => (
|
||||
<SelectItem key={member.id} value={member.username}>
|
||||
{member.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">{task.assignee || 'Unassigned'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reporter */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4" /> Reporter
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Select value={reporter || '__unassigned__'} onValueChange={setReporter}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Unknown" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unknown</SelectItem>
|
||||
{members.map((member) => (
|
||||
<SelectItem key={member.id} value={member.username}>
|
||||
{member.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">{task.reporter || 'Unknown'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Sprint */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" /> Sprint
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Select value={sprintId || '__backlog__'} onValueChange={setSprintId}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Backlog" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__backlog__">Backlog</SelectItem>
|
||||
{sprints.map((sprint) => (
|
||||
<SelectItem key={sprint.id} value={sprint.id}>
|
||||
{sprint.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">{currentSprint?.name || 'Backlog'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Target className="h-4 w-4" /> Feature
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Select value={featureId || '__none__'} onValueChange={setFeatureId}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{features.map((feature) => (
|
||||
<SelectItem key={feature.id} value={feature.id}>
|
||||
#{feature.id} - {feature.subject}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="mt-1">
|
||||
{relatedFeature ? `#${relatedFeature.id} - ${relatedFeature.subject}` : 'None'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenProject ID */}
|
||||
{isEditing && (
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">OpenProject ID</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={openProjectId}
|
||||
onChange={(e) => setOpenProjectId(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="e.g., 42"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-destructive">Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the task along with all comments and time logs.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
428
src/services/authService.ts
Normal file
428
src/services/authService.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { User, AuthData, UserRole, UserStatus } from '@/types/auth';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
const STORAGE_KEY = 'auth_data';
|
||||
const API_BASE = '/api/auth';
|
||||
|
||||
// Email service URL - can be configured via environment
|
||||
const EMAIL_SERVICE_URL = import.meta.env.VITE_EMAIL_SERVICE_URL || 'http://localhost:3001';
|
||||
|
||||
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Simple hash function for demo purposes (in production, use bcrypt on server)
|
||||
const simpleHash = (str: string): string => {
|
||||
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 with default admin user
|
||||
const getDefaultData = (): AuthData => ({
|
||||
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(),
|
||||
},
|
||||
],
|
||||
currentUser: null,
|
||||
});
|
||||
|
||||
// Local storage helpers
|
||||
const readLocalData = (): AuthData => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return getDefaultData();
|
||||
}
|
||||
}
|
||||
const defaultData = getDefaultData();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultData));
|
||||
return defaultData;
|
||||
};
|
||||
|
||||
const writeLocalData = (data: AuthData): void => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Session storage for current user (works with both API and localStorage)
|
||||
const SESSION_KEY = 'current_user';
|
||||
|
||||
const setCurrentUserSession = (user: User | null): void => {
|
||||
if (user) {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentUserSession = (): User | null => {
|
||||
const stored = sessionStorage.getItem(SESSION_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Send email notification (fire and forget)
|
||||
const sendEmail = async (endpoint: string, body: Record<string, unknown>): Promise<void> => {
|
||||
try {
|
||||
await fetch(`${EMAIL_SERVICE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to send email notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== AUTH OPERATIONS ====================
|
||||
|
||||
export const login = async (username: string, password: string): Promise<{ success: boolean; message: string; user?: User }> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const hashedPassword = simpleHash(password);
|
||||
|
||||
const user = data.users.find(
|
||||
u => u.username === username && u.password === hashedPassword
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
if (user.status === 'pending') {
|
||||
return { success: false, message: 'Your account is pending approval. Please wait for admin confirmation.' };
|
||||
}
|
||||
|
||||
if (user.status === 'rejected') {
|
||||
return { success: false, message: 'Your account registration was not approved. Please contact the administrator.' };
|
||||
}
|
||||
|
||||
data.currentUser = user;
|
||||
writeLocalData(data);
|
||||
setCurrentUserSession(user);
|
||||
return { success: true, message: 'Login successful', user };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{ username: string; password: string }, { success: boolean; message: string; user?: User }>(
|
||||
`${API_BASE}/login`,
|
||||
{ username, password }
|
||||
);
|
||||
|
||||
if (result.success && result.user) {
|
||||
setCurrentUserSession(result.user);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Login failed. Please try again.' };
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
data.currentUser = null;
|
||||
writeLocalData(data);
|
||||
}
|
||||
setCurrentUserSession(null);
|
||||
};
|
||||
|
||||
export const getCurrentUser = async (): Promise<User | null> => {
|
||||
// First check session storage
|
||||
const sessionUser = getCurrentUserSession();
|
||||
if (sessionUser) {
|
||||
return sessionUser;
|
||||
}
|
||||
|
||||
// For localStorage mode, also check localStorage
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.currentUser;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const register = async (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
|
||||
if (data.users.some(u => u.username === username)) {
|
||||
return { success: false, message: 'Username already taken' };
|
||||
}
|
||||
|
||||
if (data.users.some(u => u.email === email)) {
|
||||
return { success: false, message: 'Email already registered' };
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: generateId(),
|
||||
username,
|
||||
email,
|
||||
password: simpleHash(password),
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
data.users.push(newUser);
|
||||
writeLocalData(data);
|
||||
|
||||
sendEmail('/send/welcome', { to: email, username });
|
||||
sendEmail('/send/admin-notification', { username, email });
|
||||
|
||||
return { success: true, message: 'Registration successful! Please wait for admin approval.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{ username: string; email: string; password: string }, { success: boolean; message: string }>(
|
||||
`${API_BASE}/register`,
|
||||
{ username, email, password }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
sendEmail('/send/welcome', { to: email, username });
|
||||
sendEmail('/send/admin-notification', { username, email });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Registration failed. Please try again.' };
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== USER MANAGEMENT (Admin only) ====================
|
||||
|
||||
export const getAllUsers = async (): Promise<User[]> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.users;
|
||||
}
|
||||
|
||||
try {
|
||||
return await apiClient.get<User[]>(`${API_BASE}/users`) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getPendingUsers = async (): Promise<User[]> => {
|
||||
const users = await getAllUsers();
|
||||
return users.filter(u => u.status === 'pending');
|
||||
};
|
||||
|
||||
export const getApprovedMembers = async (): Promise<User[]> => {
|
||||
const users = await getAllUsers();
|
||||
return users.filter(u => u.status === 'approved');
|
||||
};
|
||||
|
||||
export const approveUser = async (userId: string): Promise<User> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.users.findIndex(u => u.id === userId);
|
||||
if (index === -1) throw new Error('User not found');
|
||||
|
||||
data.users[index].status = 'approved';
|
||||
data.users[index].updatedAt = new Date().toISOString();
|
||||
writeLocalData(data);
|
||||
|
||||
sendEmail('/send/approval', {
|
||||
to: data.users[index].email,
|
||||
username: data.users[index].username
|
||||
});
|
||||
|
||||
return data.users[index];
|
||||
}
|
||||
|
||||
const user = await apiClient.put<{ status: UserStatus }, User>(
|
||||
`${API_BASE}/users/${userId}`,
|
||||
{ status: 'approved' }
|
||||
);
|
||||
|
||||
sendEmail('/send/approval', { to: user.email, username: user.username });
|
||||
return user;
|
||||
};
|
||||
|
||||
export const rejectUser = async (userId: string, reason?: string): Promise<User> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.users.findIndex(u => u.id === userId);
|
||||
if (index === -1) throw new Error('User not found');
|
||||
|
||||
data.users[index].status = 'rejected';
|
||||
data.users[index].updatedAt = new Date().toISOString();
|
||||
writeLocalData(data);
|
||||
|
||||
sendEmail('/send/rejection', {
|
||||
to: data.users[index].email,
|
||||
username: data.users[index].username,
|
||||
reason
|
||||
});
|
||||
|
||||
return data.users[index];
|
||||
}
|
||||
|
||||
const user = await apiClient.put<{ status: UserStatus }, User>(
|
||||
`${API_BASE}/users/${userId}`,
|
||||
{ status: 'rejected' }
|
||||
);
|
||||
|
||||
sendEmail('/send/rejection', { to: user.email, username: user.username, reason });
|
||||
return user;
|
||||
};
|
||||
|
||||
export const addUser = async (
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
role: UserRole,
|
||||
addedBy?: string
|
||||
): Promise<User> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
|
||||
if (data.users.some(u => u.username === username)) {
|
||||
throw new Error('Username already taken');
|
||||
}
|
||||
|
||||
if (data.users.some(u => u.email === email)) {
|
||||
throw new Error('Email already registered');
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: generateId(),
|
||||
username,
|
||||
email,
|
||||
password: simpleHash(password),
|
||||
role,
|
||||
status: 'approved',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
data.users.push(newUser);
|
||||
writeLocalData(data);
|
||||
|
||||
sendEmail('/send/member-added', { to: email, username, addedBy });
|
||||
return newUser;
|
||||
}
|
||||
|
||||
const user = await apiClient.post<{ username: string; email: string; password: string; role: UserRole }, User>(
|
||||
`${API_BASE}/users`,
|
||||
{ username, email, password, role }
|
||||
);
|
||||
|
||||
sendEmail('/send/member-added', { to: email, username, addedBy });
|
||||
return user;
|
||||
};
|
||||
|
||||
export const updateUser = async (userId: string, updates: Partial<Pick<User, 'username' | 'email' | 'role'>>): Promise<User> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.users.findIndex(u => u.id === userId);
|
||||
if (index === -1) throw new Error('User not found');
|
||||
|
||||
if (updates.username && data.users.some(u => u.username === updates.username && u.id !== userId)) {
|
||||
throw new Error('Username already taken');
|
||||
}
|
||||
if (updates.email && data.users.some(u => u.email === updates.email && u.id !== userId)) {
|
||||
throw new Error('Email already registered');
|
||||
}
|
||||
|
||||
data.users[index] = {
|
||||
...data.users[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeLocalData(data);
|
||||
|
||||
return data.users[index];
|
||||
}
|
||||
|
||||
return apiClient.put<Partial<User>, User>(`${API_BASE}/users/${userId}`, updates);
|
||||
};
|
||||
|
||||
export const deleteUser = async (userId: string): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const user = data.users.find(u => u.id === userId);
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const admins = data.users.filter(u => u.role === 'admin' && u.status === 'approved');
|
||||
if (user.role === 'admin' && admins.length === 1) {
|
||||
throw new Error('Cannot delete the last admin');
|
||||
}
|
||||
|
||||
data.users = data.users.filter(u => u.id !== userId);
|
||||
writeLocalData(data);
|
||||
|
||||
sendEmail('/send/member-removed', { to: user.email, username: user.username });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await getAllUsers();
|
||||
const user = users.find(u => u.id === userId);
|
||||
|
||||
await apiClient.delete(`${API_BASE}/users/${userId}`);
|
||||
|
||||
if (user) {
|
||||
sendEmail('/send/member-removed', { to: user.email, username: user.username });
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPassword = async (userId: string, newPassword: string): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.users.findIndex(u => u.id === userId);
|
||||
if (index === -1) throw new Error('User not found');
|
||||
|
||||
data.users[index].password = simpleHash(newPassword);
|
||||
data.users[index].updatedAt = new Date().toISOString();
|
||||
writeLocalData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.put(`${API_BASE}/users/${userId}`, { password: newPassword });
|
||||
};
|
||||
|
||||
// ==================== SERVICE EXPORT ====================
|
||||
|
||||
export const authService = {
|
||||
login,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
register,
|
||||
getAllUsers,
|
||||
getPendingUsers,
|
||||
getApprovedMembers,
|
||||
approveUser,
|
||||
rejectUser,
|
||||
addUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
resetPassword,
|
||||
};
|
||||
841
src/services/budgetService.ts
Normal file
841
src/services/budgetService.ts
Normal file
@@ -0,0 +1,841 @@
|
||||
import {
|
||||
Transaction,
|
||||
Founder,
|
||||
BudgetData,
|
||||
BudgetSettings,
|
||||
BudgetSummary,
|
||||
BudgetTarget,
|
||||
TransactionType,
|
||||
CategorySpending,
|
||||
RecurringTransaction,
|
||||
} from '@/types/budget';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
const STORAGE_KEY = 'budget_data';
|
||||
const API_BASE = '/api/budget';
|
||||
|
||||
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const getDefaultSettings = (): BudgetSettings => ({
|
||||
currency: 'USD',
|
||||
fiscalYearStart: 1,
|
||||
lowRunwayWarning: 6,
|
||||
});
|
||||
|
||||
// Sample data for initial setup
|
||||
const getSampleTransactions = (): Transaction[] => {
|
||||
const now = new Date();
|
||||
const transactions: Transaction[] = [];
|
||||
|
||||
for (let monthOffset = 5; monthOffset >= 0; monthOffset--) {
|
||||
const date = new Date(now);
|
||||
date.setMonth(date.getMonth() - monthOffset);
|
||||
const monthStr = date.toISOString().substring(0, 7);
|
||||
|
||||
transactions.push({
|
||||
id: generateId(),
|
||||
date: `${monthStr}-01`,
|
||||
description: 'Investment Round Tranche',
|
||||
category: 'Investment',
|
||||
amount: 50000,
|
||||
type: 'Income',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
transactions.push({
|
||||
id: generateId(),
|
||||
date: `${monthStr}-05`,
|
||||
description: 'AWS Cloud Services',
|
||||
category: 'Cloud/Subscription',
|
||||
amount: 2500,
|
||||
type: 'Expense',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
transactions.push({
|
||||
id: generateId(),
|
||||
date: `${monthStr}-10`,
|
||||
description: 'Engineering Team Salaries',
|
||||
category: 'Salary',
|
||||
amount: 25000,
|
||||
type: 'Expense',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
transactions.push({
|
||||
id: generateId(),
|
||||
date: `${monthStr}-15`,
|
||||
description: 'Office Rent - Downtown',
|
||||
category: 'Rent',
|
||||
amount: 3500,
|
||||
type: 'Expense',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
transactions.push({
|
||||
id: generateId(),
|
||||
date: `${monthStr}-20`,
|
||||
description: 'Google Ads Campaign',
|
||||
category: 'Marketing',
|
||||
amount: 5000,
|
||||
type: 'Expense',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (monthOffset % 2 === 0) {
|
||||
transactions.push({
|
||||
id: generateId(),
|
||||
date: `${monthStr}-25`,
|
||||
description: 'Development Laptops',
|
||||
category: 'Hardware',
|
||||
amount: 4500,
|
||||
type: 'Expense',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return transactions;
|
||||
};
|
||||
|
||||
const getSampleFounders = (): Founder[] => [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Ahmad Hassan',
|
||||
initialContribution: 100000,
|
||||
additionalFunding: 50000,
|
||||
totalContributed: 150000,
|
||||
sharePercentage: 40,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Sara Ahmed',
|
||||
initialContribution: 75000,
|
||||
additionalFunding: 25000,
|
||||
totalContributed: 100000,
|
||||
sharePercentage: 30,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Mohammed Ali',
|
||||
initialContribution: 50000,
|
||||
additionalFunding: 30000,
|
||||
totalContributed: 80000,
|
||||
sharePercentage: 20,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Investor Pool',
|
||||
initialContribution: 25000,
|
||||
additionalFunding: 0,
|
||||
totalContributed: 25000,
|
||||
sharePercentage: 10,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const getSampleTargets = (): BudgetTarget[] => [
|
||||
{
|
||||
id: generateId(),
|
||||
category: 'Salary',
|
||||
monthlyLimit: 30000,
|
||||
alertThreshold: 80,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
category: 'Cloud/Subscription',
|
||||
monthlyLimit: 3000,
|
||||
alertThreshold: 75,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
category: 'Marketing',
|
||||
monthlyLimit: 6000,
|
||||
alertThreshold: 80,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const getSampleRecurringTransactions = (): RecurringTransaction[] => {
|
||||
const now = new Date();
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
return [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'AWS Cloud Services',
|
||||
description: 'Monthly AWS infrastructure costs',
|
||||
category: 'Cloud/Subscription',
|
||||
amount: 2500,
|
||||
type: 'Expense',
|
||||
frequency: 'monthly',
|
||||
nextRunDate: nextMonth.toISOString().substring(0, 10),
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Engineering Salaries',
|
||||
description: 'Monthly team salaries',
|
||||
category: 'Salary',
|
||||
amount: 25000,
|
||||
type: 'Expense',
|
||||
frequency: 'monthly',
|
||||
nextRunDate: nextMonth.toISOString().substring(0, 10),
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'Office Rent',
|
||||
description: 'Downtown office monthly rent',
|
||||
category: 'Rent',
|
||||
amount: 3500,
|
||||
type: 'Expense',
|
||||
frequency: 'monthly',
|
||||
nextRunDate: nextMonth.toISOString().substring(0, 10),
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getDefaultData = (): BudgetData => ({
|
||||
transactions: getSampleTransactions(),
|
||||
founders: getSampleFounders(),
|
||||
settings: getDefaultSettings(),
|
||||
targets: getSampleTargets(),
|
||||
recurringTransactions: getSampleRecurringTransactions(),
|
||||
});
|
||||
|
||||
// Local storage helpers
|
||||
const readLocalData = (): BudgetData => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored);
|
||||
return {
|
||||
transactions: data.transactions || [],
|
||||
founders: data.founders || [],
|
||||
settings: { ...getDefaultSettings(), ...data.settings },
|
||||
targets: data.targets || [],
|
||||
recurringTransactions: data.recurringTransactions || [],
|
||||
};
|
||||
} catch {
|
||||
return getDefaultData();
|
||||
}
|
||||
}
|
||||
const defaultData = getDefaultData();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultData));
|
||||
return defaultData;
|
||||
};
|
||||
|
||||
const writeLocalData = (data: BudgetData): void => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// ==================== TRANSACTION OPERATIONS ====================
|
||||
|
||||
export const getTransactions = async (): Promise<Transaction[]> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.transactions.sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
const transactions = await apiClient.get<Transaction[]>(`${API_BASE}/transactions`);
|
||||
return (transactions || []).sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
};
|
||||
|
||||
export const getTransaction = async (id: string): Promise<Transaction | null> => {
|
||||
const transactions = await getTransactions();
|
||||
return transactions.find(t => t.id === id) ?? null;
|
||||
};
|
||||
|
||||
export const createTransaction = async (
|
||||
transaction: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<Transaction> => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const newTransaction: Transaction = {
|
||||
...transaction,
|
||||
id: generateId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.transactions.push(newTransaction);
|
||||
writeLocalData(data);
|
||||
return newTransaction;
|
||||
}
|
||||
|
||||
return apiClient.post<typeof transaction, Transaction>(`${API_BASE}/transactions`, transaction);
|
||||
};
|
||||
|
||||
export const updateTransaction = async (
|
||||
id: string,
|
||||
updates: Partial<Transaction>
|
||||
): Promise<Transaction> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.transactions.findIndex(t => t.id === id);
|
||||
if (index === -1) throw new Error('Transaction not found');
|
||||
|
||||
data.transactions[index] = {
|
||||
...data.transactions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeLocalData(data);
|
||||
return data.transactions[index];
|
||||
}
|
||||
|
||||
return apiClient.put<Partial<Transaction>, Transaction>(`${API_BASE}/transactions/${id}`, updates);
|
||||
};
|
||||
|
||||
export const deleteTransaction = async (id: string): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
data.transactions = data.transactions.filter(t => t.id !== id);
|
||||
writeLocalData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.delete(`${API_BASE}/transactions/${id}`);
|
||||
};
|
||||
|
||||
// ==================== FOUNDER OPERATIONS ====================
|
||||
|
||||
export const getFounders = async (): Promise<Founder[]> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.founders.sort((a, b) => b.sharePercentage - a.sharePercentage);
|
||||
}
|
||||
|
||||
const founders = await apiClient.get<Founder[]>(`${API_BASE}/founders`);
|
||||
return (founders || []).sort((a, b) => b.sharePercentage - a.sharePercentage);
|
||||
};
|
||||
|
||||
export const createFounder = async (
|
||||
founder: Omit<Founder, 'id' | 'createdAt' | 'updatedAt' | 'totalContributed'>
|
||||
): Promise<Founder> => {
|
||||
const now = new Date().toISOString();
|
||||
const newFounder = {
|
||||
...founder,
|
||||
totalContributed: founder.initialContribution + founder.additionalFunding,
|
||||
};
|
||||
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const created: Founder = {
|
||||
...newFounder,
|
||||
id: generateId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.founders.push(created);
|
||||
writeLocalData(data);
|
||||
return created;
|
||||
}
|
||||
|
||||
return apiClient.post<typeof newFounder, Founder>(`${API_BASE}/founders`, newFounder);
|
||||
};
|
||||
|
||||
export const updateFounder = async (
|
||||
id: string,
|
||||
updates: Partial<Founder>
|
||||
): Promise<Founder> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.founders.findIndex(f => f.id === id);
|
||||
if (index === -1) throw new Error('Founder not found');
|
||||
|
||||
const updated = {
|
||||
...data.founders[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
updated.totalContributed = updated.initialContribution + updated.additionalFunding;
|
||||
|
||||
data.founders[index] = updated;
|
||||
writeLocalData(data);
|
||||
return data.founders[index];
|
||||
}
|
||||
|
||||
return apiClient.put<Partial<Founder>, Founder>(`${API_BASE}/founders/${id}`, updates);
|
||||
};
|
||||
|
||||
export const deleteFounder = async (id: string): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
data.founders = data.founders.filter(f => f.id !== id);
|
||||
writeLocalData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.delete(`${API_BASE}/founders/${id}`);
|
||||
};
|
||||
|
||||
// ==================== TARGET OPERATIONS ====================
|
||||
|
||||
export const getTargets = async (): Promise<BudgetTarget[]> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.targets;
|
||||
}
|
||||
|
||||
return await apiClient.get<BudgetTarget[]>(`${API_BASE}/targets`) || [];
|
||||
};
|
||||
|
||||
export const createTarget = async (
|
||||
target: Omit<BudgetTarget, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<BudgetTarget> => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const existing = data.targets.find(t => t.category === target.category);
|
||||
if (existing) {
|
||||
throw new Error(`Target for ${target.category} already exists`);
|
||||
}
|
||||
|
||||
const newTarget: BudgetTarget = {
|
||||
...target,
|
||||
id: generateId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.targets.push(newTarget);
|
||||
writeLocalData(data);
|
||||
return newTarget;
|
||||
}
|
||||
|
||||
const targets = await getTargets();
|
||||
const existing = targets.find(t => t.category === target.category);
|
||||
if (existing) {
|
||||
throw new Error(`Target for ${target.category} already exists`);
|
||||
}
|
||||
|
||||
const allTargets = [...targets, { ...target, id: generateId(), createdAt: now, updatedAt: now }];
|
||||
await apiClient.put(`${API_BASE}/targets`, allTargets);
|
||||
return allTargets[allTargets.length - 1];
|
||||
};
|
||||
|
||||
export const updateTarget = async (
|
||||
id: string,
|
||||
updates: Partial<BudgetTarget>
|
||||
): Promise<BudgetTarget> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.targets.findIndex(t => t.id === id);
|
||||
if (index === -1) throw new Error('Target not found');
|
||||
|
||||
data.targets[index] = {
|
||||
...data.targets[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeLocalData(data);
|
||||
return data.targets[index];
|
||||
}
|
||||
|
||||
const targets = await getTargets();
|
||||
const index = targets.findIndex(t => t.id === id);
|
||||
if (index === -1) throw new Error('Target not found');
|
||||
|
||||
targets[index] = { ...targets[index], ...updates, updatedAt: new Date().toISOString() };
|
||||
await apiClient.put(`${API_BASE}/targets`, targets);
|
||||
return targets[index];
|
||||
};
|
||||
|
||||
export const deleteTarget = async (id: string): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
data.targets = data.targets.filter(t => t.id !== id);
|
||||
writeLocalData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = await getTargets();
|
||||
const filtered = targets.filter(t => t.id !== id);
|
||||
await apiClient.put(`${API_BASE}/targets`, filtered);
|
||||
};
|
||||
|
||||
// ==================== RECURRING TRANSACTION OPERATIONS ====================
|
||||
|
||||
export const getRecurringTransactions = async (): Promise<RecurringTransaction[]> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.recurringTransactions;
|
||||
}
|
||||
|
||||
return await apiClient.get<RecurringTransaction[]>(`${API_BASE}/recurring`) || [];
|
||||
};
|
||||
|
||||
export const createRecurringTransaction = async (
|
||||
recurring: Omit<RecurringTransaction, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<RecurringTransaction> => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const newRecurring: RecurringTransaction = {
|
||||
...recurring,
|
||||
id: generateId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.recurringTransactions.push(newRecurring);
|
||||
writeLocalData(data);
|
||||
return newRecurring;
|
||||
}
|
||||
|
||||
return apiClient.post<typeof recurring, RecurringTransaction>(`${API_BASE}/recurring`, recurring);
|
||||
};
|
||||
|
||||
export const updateRecurringTransaction = async (
|
||||
id: string,
|
||||
updates: Partial<RecurringTransaction>
|
||||
): Promise<RecurringTransaction> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
const index = data.recurringTransactions.findIndex(r => r.id === id);
|
||||
if (index === -1) throw new Error('Recurring transaction not found');
|
||||
|
||||
data.recurringTransactions[index] = {
|
||||
...data.recurringTransactions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeLocalData(data);
|
||||
return data.recurringTransactions[index];
|
||||
}
|
||||
|
||||
return apiClient.put<Partial<RecurringTransaction>, RecurringTransaction>(
|
||||
`${API_BASE}/recurring/${id}`,
|
||||
updates
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteRecurringTransaction = async (id: string): Promise<void> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
data.recurringTransactions = data.recurringTransactions.filter(r => r.id !== id);
|
||||
writeLocalData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.delete(`${API_BASE}/recurring/${id}`);
|
||||
};
|
||||
|
||||
export const processRecurringTransactions = async (): Promise<number> => {
|
||||
const recurringList = await getRecurringTransactions();
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
let processed = 0;
|
||||
|
||||
for (const recurring of recurringList) {
|
||||
if (!recurring.isActive) continue;
|
||||
if (recurring.nextRunDate > today) continue;
|
||||
|
||||
// Create the transaction
|
||||
await createTransaction({
|
||||
date: recurring.nextRunDate,
|
||||
description: `${recurring.name} (Auto-generated)`,
|
||||
category: recurring.category,
|
||||
amount: recurring.amount,
|
||||
type: recurring.type,
|
||||
});
|
||||
|
||||
// Calculate next run date
|
||||
const nextDate = new Date(recurring.nextRunDate);
|
||||
switch (recurring.frequency) {
|
||||
case 'weekly':
|
||||
nextDate.setDate(nextDate.getDate() + 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
nextDate.setMonth(nextDate.getMonth() + 1);
|
||||
break;
|
||||
case 'yearly':
|
||||
nextDate.setFullYear(nextDate.getFullYear() + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
await updateRecurringTransaction(recurring.id, {
|
||||
lastRunDate: recurring.nextRunDate,
|
||||
nextRunDate: nextDate.toISOString().substring(0, 10),
|
||||
});
|
||||
|
||||
processed++;
|
||||
}
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
// ==================== SETTINGS OPERATIONS ====================
|
||||
|
||||
export const getSettings = async (): Promise<BudgetSettings> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
return await apiClient.get<BudgetSettings>(`${API_BASE}/settings`) || getDefaultSettings();
|
||||
};
|
||||
|
||||
export const updateSettings = async (updates: Partial<BudgetSettings>): Promise<BudgetSettings> => {
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
const data = readLocalData();
|
||||
data.settings = { ...data.settings, ...updates };
|
||||
writeLocalData(data);
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
return apiClient.put<Partial<BudgetSettings>, BudgetSettings>(`${API_BASE}/settings`, updates);
|
||||
};
|
||||
|
||||
// ==================== ANALYTICS ====================
|
||||
|
||||
const getCurrentMonthExpensesByCategory = (transactions: Transaction[]): Record<string, number> => {
|
||||
const now = new Date();
|
||||
const currentMonth = now.toISOString().substring(0, 7);
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
transactions
|
||||
.filter(t => t.type === 'Expense' && t.date.startsWith(currentMonth))
|
||||
.forEach(t => {
|
||||
result[t.category] = (result[t.category] || 0) + t.amount;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getBudgetSummary = async (): Promise<BudgetSummary> => {
|
||||
const transactions = await getTransactions();
|
||||
const targets = await getTargets();
|
||||
|
||||
const totalIncome = transactions
|
||||
.filter(t => t.type === 'Income')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const totalExpenses = transactions
|
||||
.filter(t => t.type === 'Expense')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const totalBalance = totalIncome - totalExpenses;
|
||||
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
const recentExpenses = transactions.filter(t =>
|
||||
t.type === 'Expense' && new Date(t.date) >= sixMonthsAgo
|
||||
);
|
||||
|
||||
const expensesByMonth: Record<string, number> = {};
|
||||
recentExpenses.forEach(t => {
|
||||
const monthKey = t.date.substring(0, 7);
|
||||
expensesByMonth[monthKey] = (expensesByMonth[monthKey] || 0) + t.amount;
|
||||
});
|
||||
|
||||
const monthlyExpenses = Object.values(expensesByMonth);
|
||||
const monthlyBurnRate = monthlyExpenses.length > 0
|
||||
? monthlyExpenses.reduce((a, b) => a + b, 0) / monthlyExpenses.length
|
||||
: 0;
|
||||
|
||||
const estimatedRunway = monthlyBurnRate > 0
|
||||
? Math.floor(totalBalance / monthlyBurnRate)
|
||||
: totalBalance > 0 ? Infinity : 0;
|
||||
|
||||
const expensesByCategory: Record<string, number> = {};
|
||||
transactions
|
||||
.filter(t => t.type === 'Expense')
|
||||
.forEach(t => {
|
||||
expensesByCategory[t.category] = (expensesByCategory[t.category] || 0) + t.amount;
|
||||
});
|
||||
|
||||
const currentMonthExpenses = getCurrentMonthExpensesByCategory(transactions);
|
||||
const categoryAlerts: CategorySpending[] = targets.map(target => {
|
||||
const spent = currentMonthExpenses[target.category] || 0;
|
||||
const percentUsed = target.monthlyLimit > 0 ? (spent / target.monthlyLimit) * 100 : 0;
|
||||
|
||||
return {
|
||||
category: target.category,
|
||||
spent,
|
||||
limit: target.monthlyLimit,
|
||||
alertThreshold: target.alertThreshold,
|
||||
percentUsed,
|
||||
isOverBudget: percentUsed >= 100,
|
||||
isWarning: percentUsed >= target.alertThreshold && percentUsed < 100,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
totalBalance,
|
||||
totalIncome,
|
||||
totalExpenses,
|
||||
monthlyBurnRate,
|
||||
estimatedRunway,
|
||||
expensesByCategory,
|
||||
categoryAlerts,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMonthlyData = async (months: number = 12): Promise<{
|
||||
month: string;
|
||||
income: number;
|
||||
expenses: number;
|
||||
}[]> => {
|
||||
const transactions = await getTransactions();
|
||||
const result: Record<string, { income: number; expenses: number }> = {};
|
||||
|
||||
for (let i = months - 1; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - i);
|
||||
const key = date.toISOString().substring(0, 7);
|
||||
result[key] = { income: 0, expenses: 0 };
|
||||
}
|
||||
|
||||
transactions.forEach(t => {
|
||||
const key = t.date.substring(0, 7);
|
||||
if (result[key]) {
|
||||
if (t.type === 'Income') {
|
||||
result[key].income += t.amount;
|
||||
} else {
|
||||
result[key].expenses += t.amount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(result).map(([month, data]) => ({
|
||||
month,
|
||||
...data,
|
||||
}));
|
||||
};
|
||||
|
||||
// ==================== IMPORT/EXPORT ====================
|
||||
|
||||
export const exportTransactionsCSV = async (): Promise<string> => {
|
||||
const transactions = await getTransactions();
|
||||
|
||||
const headers = ['Date', 'Description', 'Category', 'Amount', 'Type'];
|
||||
const rows = transactions.map(t => [
|
||||
t.date,
|
||||
`"${t.description.replace(/"/g, '""')}"`,
|
||||
t.category,
|
||||
t.amount.toString(),
|
||||
t.type,
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
};
|
||||
|
||||
export const importTransactionsCSV = async (csvContent: string): Promise<number> => {
|
||||
const lines = csvContent.trim().split('\n');
|
||||
if (lines.length < 2) return 0;
|
||||
|
||||
let imported = 0;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const match = line.match(/^([^,]+),(".*?"|[^,]*),([^,]+),([^,]+),([^,]+)$/);
|
||||
if (match) {
|
||||
const [, date, description, category, amount, type] = match;
|
||||
const cleanDesc = description.replace(/^"|"$/g, '').replace(/""/g, '"');
|
||||
|
||||
await createTransaction({
|
||||
date: date.trim(),
|
||||
description: cleanDesc.trim(),
|
||||
category: category.trim() as any,
|
||||
amount: parseFloat(amount.trim()),
|
||||
type: type.trim() as TransactionType,
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
};
|
||||
|
||||
export const getAllData = async (): Promise<BudgetData> => {
|
||||
const [transactions, founders, settings, targets, recurringTransactions] = await Promise.all([
|
||||
getTransactions(),
|
||||
getFounders(),
|
||||
getSettings(),
|
||||
getTargets(),
|
||||
getRecurringTransactions(),
|
||||
]);
|
||||
|
||||
return { transactions, founders, settings, targets, recurringTransactions };
|
||||
};
|
||||
|
||||
export const resetToSampleData = async (): Promise<void> => {
|
||||
const defaultData = getDefaultData();
|
||||
|
||||
if (apiClient.isUsingLocalStorage()) {
|
||||
writeLocalData(defaultData);
|
||||
return;
|
||||
}
|
||||
|
||||
// For API, we need to update each collection
|
||||
await Promise.all([
|
||||
apiClient.put(`${API_BASE}/settings`, defaultData.settings),
|
||||
apiClient.put(`${API_BASE}/targets`, defaultData.targets),
|
||||
]);
|
||||
|
||||
// Clear and recreate transactions/founders/recurring (this is simplified)
|
||||
writeLocalData(defaultData);
|
||||
};
|
||||
|
||||
// Budget Service API
|
||||
export const budgetService = {
|
||||
getTransactions,
|
||||
getTransaction,
|
||||
createTransaction,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
getRecurringTransactions,
|
||||
createRecurringTransaction,
|
||||
updateRecurringTransaction,
|
||||
deleteRecurringTransaction,
|
||||
processRecurringTransactions,
|
||||
getFounders,
|
||||
createFounder,
|
||||
updateFounder,
|
||||
deleteFounder,
|
||||
getTargets,
|
||||
createTarget,
|
||||
updateTarget,
|
||||
deleteTarget,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
getBudgetSummary,
|
||||
getMonthlyData,
|
||||
exportTransactionsCSV,
|
||||
importTransactionsCSV,
|
||||
getAllData,
|
||||
resetToSampleData,
|
||||
};
|
||||
346
src/services/planningService.ts
Normal file
346
src/services/planningService.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { Sprint, Task, TaskComment, TimeLog, PlanningData, TaskStatus, Attachment } from '@/types/planning';
|
||||
|
||||
const STORAGE_KEY = 'planning_data';
|
||||
const SIMULATED_DELAY = 100;
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const getDefaultData = (): PlanningData => ({
|
||||
sprints: [],
|
||||
tasks: [],
|
||||
comments: [],
|
||||
timeLogs: [],
|
||||
});
|
||||
|
||||
const readData = async (): Promise<PlanningData> => {
|
||||
await delay(SIMULATED_DELAY);
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored);
|
||||
// Ensure new fields exist for backwards compatibility
|
||||
return {
|
||||
sprints: data.sprints || [],
|
||||
tasks: (data.tasks || []).map((t: Task) => ({
|
||||
...t,
|
||||
storyPoints: t.storyPoints ?? null,
|
||||
estimatedHours: t.estimatedHours ?? null,
|
||||
loggedHours: t.loggedHours ?? 0,
|
||||
dueDate: t.dueDate ?? null,
|
||||
giteaBranch: t.giteaBranch ?? null,
|
||||
giteaPR: t.giteaPR ?? null,
|
||||
attachments: t.attachments ?? [],
|
||||
})),
|
||||
comments: (data.comments || []).map((c: TaskComment) => ({
|
||||
...c,
|
||||
attachments: c.attachments ?? [],
|
||||
})),
|
||||
timeLogs: data.timeLogs || [],
|
||||
};
|
||||
} catch {
|
||||
return getDefaultData();
|
||||
}
|
||||
}
|
||||
return getDefaultData();
|
||||
};
|
||||
|
||||
const writeData = async (data: PlanningData): Promise<void> => {
|
||||
await delay(SIMULATED_DELAY);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
};
|
||||
|
||||
// ==================== SPRINT OPERATIONS ====================
|
||||
|
||||
export const getSprints = async (): Promise<Sprint[]> => {
|
||||
const data = await readData();
|
||||
return data.sprints;
|
||||
};
|
||||
|
||||
export const createSprint = async (sprint: Omit<Sprint, 'id'>): Promise<Sprint> => {
|
||||
const data = await readData();
|
||||
const newSprint: Sprint = {
|
||||
...sprint,
|
||||
id: generateId(),
|
||||
};
|
||||
data.sprints.push(newSprint);
|
||||
await writeData(data);
|
||||
return newSprint;
|
||||
};
|
||||
|
||||
export const updateSprint = async (id: string, updates: Partial<Sprint>): Promise<Sprint> => {
|
||||
const data = await readData();
|
||||
const index = data.sprints.findIndex(s => s.id === id);
|
||||
if (index === -1) throw new Error('Sprint not found');
|
||||
|
||||
data.sprints[index] = { ...data.sprints[index], ...updates };
|
||||
await writeData(data);
|
||||
return data.sprints[index];
|
||||
};
|
||||
|
||||
export const deleteSprint = async (id: string): Promise<void> => {
|
||||
const data = await readData();
|
||||
data.tasks = data.tasks.map(task =>
|
||||
task.sprintId === id ? { ...task, sprintId: null } : task
|
||||
);
|
||||
data.sprints = data.sprints.filter(s => s.id !== id);
|
||||
await writeData(data);
|
||||
};
|
||||
|
||||
export const startSprint = async (id: string): Promise<Sprint> => {
|
||||
const data = await readData();
|
||||
|
||||
const hasActive = data.sprints.some(s => s.status === 'active');
|
||||
if (hasActive) throw new Error('Another sprint is already active');
|
||||
|
||||
const index = data.sprints.findIndex(s => s.id === id);
|
||||
if (index === -1) throw new Error('Sprint not found');
|
||||
|
||||
data.sprints[index].status = 'active';
|
||||
data.sprints[index].startDate = new Date().toISOString().split('T')[0];
|
||||
await writeData(data);
|
||||
return data.sprints[index];
|
||||
};
|
||||
|
||||
export const completeSprint = async (id: string): Promise<{ sprint: Sprint; returnedTasks: Task[] }> => {
|
||||
const data = await readData();
|
||||
const index = data.sprints.findIndex(s => s.id === id);
|
||||
if (index === -1) throw new Error('Sprint not found');
|
||||
|
||||
const returnedTasks: Task[] = [];
|
||||
data.tasks = data.tasks.map(task => {
|
||||
if (task.sprintId === id && task.status !== 'done') {
|
||||
returnedTasks.push({ ...task, sprintId: null });
|
||||
return { ...task, sprintId: null };
|
||||
}
|
||||
return task;
|
||||
});
|
||||
|
||||
data.sprints[index].status = 'completed';
|
||||
data.sprints[index].endDate = new Date().toISOString().split('T')[0];
|
||||
await writeData(data);
|
||||
|
||||
return { sprint: data.sprints[index], returnedTasks };
|
||||
};
|
||||
|
||||
// ==================== TASK OPERATIONS ====================
|
||||
|
||||
export const getTasks = async (): Promise<Task[]> => {
|
||||
const data = await readData();
|
||||
return data.tasks;
|
||||
};
|
||||
|
||||
export const getTask = async (id: string): Promise<Task | null> => {
|
||||
const data = await readData();
|
||||
return data.tasks.find(t => t.id === id) ?? null;
|
||||
};
|
||||
|
||||
export const getBacklogTasks = async (): Promise<Task[]> => {
|
||||
const data = await readData();
|
||||
return data.tasks.filter(t => t.sprintId === null);
|
||||
};
|
||||
|
||||
export const getSprintTasks = async (sprintId: string): Promise<Task[]> => {
|
||||
const data = await readData();
|
||||
return data.tasks.filter(t => t.sprintId === sprintId);
|
||||
};
|
||||
|
||||
export const createTask = async (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'loggedHours'>): Promise<Task> => {
|
||||
const data = await readData();
|
||||
const now = new Date().toISOString();
|
||||
const newTask: Task = {
|
||||
...task,
|
||||
id: generateId(),
|
||||
loggedHours: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.tasks.push(newTask);
|
||||
await writeData(data);
|
||||
return newTask;
|
||||
};
|
||||
|
||||
export const updateTask = async (id: string, updates: Partial<Task>): Promise<Task> => {
|
||||
const data = await readData();
|
||||
const index = data.tasks.findIndex(t => t.id === id);
|
||||
if (index === -1) throw new Error('Task not found');
|
||||
|
||||
data.tasks[index] = {
|
||||
...data.tasks[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeData(data);
|
||||
return data.tasks[index];
|
||||
};
|
||||
|
||||
export const deleteTask = async (id: string): Promise<void> => {
|
||||
const data = await readData();
|
||||
data.tasks = data.tasks.filter(t => t.id !== id);
|
||||
data.comments = data.comments.filter(c => c.taskId !== id);
|
||||
data.timeLogs = data.timeLogs.filter(t => t.taskId !== id);
|
||||
await writeData(data);
|
||||
};
|
||||
|
||||
export const moveTaskToSprint = async (taskId: string, sprintId: string | null): Promise<Task> => {
|
||||
return updateTask(taskId, { sprintId });
|
||||
};
|
||||
|
||||
export const updateTaskStatus = async (taskId: string, status: TaskStatus): Promise<Task> => {
|
||||
return updateTask(taskId, { status });
|
||||
};
|
||||
|
||||
// ==================== COMMENT OPERATIONS ====================
|
||||
|
||||
export const getTaskComments = async (taskId: string): Promise<TaskComment[]> => {
|
||||
const data = await readData();
|
||||
return data.comments.filter(c => c.taskId === taskId).sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
};
|
||||
|
||||
export const addComment = async (
|
||||
taskId: string,
|
||||
userId: string,
|
||||
username: string,
|
||||
content: string,
|
||||
attachments: Attachment[] = []
|
||||
): Promise<TaskComment> => {
|
||||
const data = await readData();
|
||||
const newComment: TaskComment = {
|
||||
id: generateId(),
|
||||
taskId,
|
||||
userId,
|
||||
username,
|
||||
content,
|
||||
attachments,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
data.comments.push(newComment);
|
||||
await writeData(data);
|
||||
return newComment;
|
||||
};
|
||||
|
||||
export const deleteComment = async (commentId: string): Promise<void> => {
|
||||
const data = await readData();
|
||||
data.comments = data.comments.filter(c => c.id !== commentId);
|
||||
await writeData(data);
|
||||
};
|
||||
|
||||
// ==================== TIME LOG OPERATIONS ====================
|
||||
|
||||
export const getTaskTimeLogs = async (taskId: string): Promise<TimeLog[]> => {
|
||||
const data = await readData();
|
||||
return data.timeLogs.filter(t => t.taskId === taskId).sort((a, b) =>
|
||||
new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime()
|
||||
);
|
||||
};
|
||||
|
||||
export const logTime = async (
|
||||
taskId: string,
|
||||
userId: string,
|
||||
username: string,
|
||||
hours: number,
|
||||
description: string
|
||||
): Promise<TimeLog> => {
|
||||
const data = await readData();
|
||||
|
||||
const newTimeLog: TimeLog = {
|
||||
id: generateId(),
|
||||
taskId,
|
||||
userId,
|
||||
username,
|
||||
hours,
|
||||
description,
|
||||
loggedAt: new Date().toISOString(),
|
||||
};
|
||||
data.timeLogs.push(newTimeLog);
|
||||
|
||||
// Update task's logged hours
|
||||
const taskIndex = data.tasks.findIndex(t => t.id === taskId);
|
||||
if (taskIndex !== -1) {
|
||||
data.tasks[taskIndex].loggedHours = (data.tasks[taskIndex].loggedHours || 0) + hours;
|
||||
data.tasks[taskIndex].updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
await writeData(data);
|
||||
return newTimeLog;
|
||||
};
|
||||
|
||||
export const deleteTimeLog = async (timeLogId: string): Promise<void> => {
|
||||
const data = await readData();
|
||||
const timeLog = data.timeLogs.find(t => t.id === timeLogId);
|
||||
if (timeLog) {
|
||||
// Update task's logged hours
|
||||
const taskIndex = data.tasks.findIndex(t => t.id === timeLog.taskId);
|
||||
if (taskIndex !== -1) {
|
||||
data.tasks[taskIndex].loggedHours = Math.max(0, (data.tasks[taskIndex].loggedHours || 0) - timeLog.hours);
|
||||
data.tasks[taskIndex].updatedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
data.timeLogs = data.timeLogs.filter(t => t.id !== timeLogId);
|
||||
await writeData(data);
|
||||
};
|
||||
|
||||
// ==================== VELOCITY CALCULATIONS ====================
|
||||
|
||||
export const getSprintVelocity = async (sprintId: string): Promise<{ planned: number; completed: number }> => {
|
||||
const data = await readData();
|
||||
const sprintTasks = data.tasks.filter(t => t.sprintId === sprintId);
|
||||
|
||||
const planned = sprintTasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||
const completed = sprintTasks
|
||||
.filter(t => t.status === 'done')
|
||||
.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
||||
|
||||
return { planned, completed };
|
||||
};
|
||||
|
||||
// ==================== BULK OPERATIONS ====================
|
||||
|
||||
export const getAllData = async (): Promise<PlanningData> => {
|
||||
return readData();
|
||||
};
|
||||
|
||||
export const importData = async (data: PlanningData): Promise<void> => {
|
||||
await writeData(data);
|
||||
};
|
||||
|
||||
// Planning Service API
|
||||
export const planningService = {
|
||||
// Sprints
|
||||
getSprints,
|
||||
createSprint,
|
||||
updateSprint,
|
||||
deleteSprint,
|
||||
startSprint,
|
||||
completeSprint,
|
||||
|
||||
// Tasks
|
||||
getTasks,
|
||||
getTask,
|
||||
getBacklogTasks,
|
||||
getSprintTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
moveTaskToSprint,
|
||||
updateTaskStatus,
|
||||
|
||||
// Comments
|
||||
getTaskComments,
|
||||
addComment,
|
||||
deleteComment,
|
||||
|
||||
// Time Logs
|
||||
getTaskTimeLogs,
|
||||
logTime,
|
||||
deleteTimeLog,
|
||||
|
||||
// Velocity
|
||||
getSprintVelocity,
|
||||
|
||||
// Bulk
|
||||
getAllData,
|
||||
importData,
|
||||
};
|
||||
18
src/types/auth.ts
Normal file
18
src/types/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type UserRole = 'admin' | 'member';
|
||||
export type UserStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string; // In production, this should be hashed
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuthData {
|
||||
users: User[];
|
||||
currentUser: User | null;
|
||||
}
|
||||
144
src/types/budget.ts
Normal file
144
src/types/budget.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
export type TransactionType = 'Income' | 'Expense';
|
||||
|
||||
export type ExpenseCategory =
|
||||
| 'Purchase'
|
||||
| 'Salary'
|
||||
| 'Cloud/Subscription'
|
||||
| 'Marketing'
|
||||
| 'Rent'
|
||||
| 'Utilities'
|
||||
| 'Hardware'
|
||||
| 'Software'
|
||||
| 'Travel'
|
||||
| 'Legal'
|
||||
| 'Insurance'
|
||||
| 'Office Supplies'
|
||||
| 'Consulting'
|
||||
| 'Other';
|
||||
|
||||
export type IncomeCategory =
|
||||
| 'Investment'
|
||||
| 'Revenue'
|
||||
| 'Founder Contribution'
|
||||
| 'Grant'
|
||||
| 'Loan'
|
||||
| 'Other';
|
||||
|
||||
export interface TransactionAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
dataUrl: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
date: string;
|
||||
description: string;
|
||||
category: ExpenseCategory | IncomeCategory;
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
attachments?: TransactionAttachment[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Founder {
|
||||
id: string;
|
||||
name: string;
|
||||
initialContribution: number;
|
||||
additionalFunding: number;
|
||||
totalContributed: number;
|
||||
sharePercentage: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetTarget {
|
||||
id: string;
|
||||
category: ExpenseCategory;
|
||||
monthlyLimit: number;
|
||||
alertThreshold: number; // Percentage (0-100) at which to show warning
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetSettings {
|
||||
currency: string;
|
||||
fiscalYearStart: number; // Month 1-12
|
||||
lowRunwayWarning: number; // Months
|
||||
}
|
||||
|
||||
export interface BudgetData {
|
||||
transactions: Transaction[];
|
||||
founders: Founder[];
|
||||
settings: BudgetSettings;
|
||||
targets: BudgetTarget[];
|
||||
recurringTransactions: RecurringTransaction[];
|
||||
}
|
||||
|
||||
export interface CategorySpending {
|
||||
category: ExpenseCategory;
|
||||
spent: number;
|
||||
limit: number;
|
||||
alertThreshold: number;
|
||||
percentUsed: number;
|
||||
isOverBudget: boolean;
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetSummary {
|
||||
totalBalance: number;
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
monthlyBurnRate: number;
|
||||
estimatedRunway: number;
|
||||
expensesByCategory: Record<string, number>;
|
||||
categoryAlerts: CategorySpending[];
|
||||
}
|
||||
|
||||
// Category icons mapping
|
||||
export const EXPENSE_CATEGORIES: ExpenseCategory[] = [
|
||||
'Purchase',
|
||||
'Salary',
|
||||
'Cloud/Subscription',
|
||||
'Marketing',
|
||||
'Rent',
|
||||
'Utilities',
|
||||
'Hardware',
|
||||
'Software',
|
||||
'Travel',
|
||||
'Legal',
|
||||
'Insurance',
|
||||
'Office Supplies',
|
||||
'Consulting',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const INCOME_CATEGORIES: IncomeCategory[] = [
|
||||
'Investment',
|
||||
'Revenue',
|
||||
'Founder Contribution',
|
||||
'Grant',
|
||||
'Loan',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export type RecurringFrequency = 'monthly' | 'weekly' | 'yearly';
|
||||
|
||||
export interface RecurringTransaction {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ExpenseCategory | IncomeCategory;
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
frequency: RecurringFrequency;
|
||||
nextRunDate: string;
|
||||
lastRunDate?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
72
src/types/planning.ts
Normal file
72
src/types/planning.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type SprintStatus = 'planning' | 'active' | 'completed';
|
||||
export type TaskType = 'Story' | 'Bug' | 'Task';
|
||||
export type TaskPriority = 'High' | 'Med' | 'Low';
|
||||
export type TaskStatus = 'todo' | 'inprogress' | 'review' | 'done';
|
||||
|
||||
export interface Sprint {
|
||||
id: string;
|
||||
name: string;
|
||||
goal: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: SprintStatus;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string; // Data URL or external URL
|
||||
type: string; // MIME type
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
taskId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
content: string;
|
||||
attachments: Attachment[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimeLog {
|
||||
id: string;
|
||||
taskId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
hours: number;
|
||||
description: string;
|
||||
loggedAt: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
sprintId: string | null; // null means in backlog
|
||||
title: string;
|
||||
description: string;
|
||||
type: TaskType;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
assignee: string | null; // Person assigned to work on the task
|
||||
reporter: string | null; // Person who created/reported the task
|
||||
featureId: string | null; // Optional link to a feature (from traceability data)
|
||||
openProjectId?: number; // Optional link to OpenProject work package
|
||||
storyPoints: number | null; // Story points for velocity calculation
|
||||
estimatedHours: number | null; // Estimated hours
|
||||
loggedHours: number; // Total logged hours
|
||||
dueDate: string | null; // Due date for the task
|
||||
giteaBranch: string | null; // Gitea branch URL
|
||||
giteaPR: string | null; // Gitea pull request URL
|
||||
attachments: Attachment[]; // Task description attachments
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlanningData {
|
||||
sprints: Sprint[];
|
||||
tasks: Task[];
|
||||
comments: TaskComment[];
|
||||
timeLogs: TimeLog[];
|
||||
}
|
||||
Reference in New Issue
Block a user