new features

This commit is contained in:
2026-02-03 20:48:09 +01:00
parent 39655c2913
commit a4739df7de
52 changed files with 11740 additions and 129 deletions

122
DATA-BACKUP-GUIDE.md Normal file
View 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
View 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
View 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
View 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"
}
}

211
deploy.sh
View File

@@ -7,6 +7,10 @@
# using Docker Compose with Caddy reverse proxy # using Docker Compose with Caddy reverse proxy
# #
# Domain: Traceability.nabd-co.com # 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 set -e
@@ -22,7 +26,8 @@ NC='\033[0m' # No Color
APP_NAME="traceability" APP_NAME="traceability"
APP_DIR="/opt/traceability" APP_DIR="/opt/traceability"
CADDY_DIR="/root/caddy" 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}============================================${NC}"
echo -e "${BLUE} ASF Traceability Matrix Deployment${NC}" echo -e "${BLUE} ASF Traceability Matrix Deployment${NC}"
@@ -47,6 +52,110 @@ if [ "$EUID" -ne 0 ]; then
exit 1 exit 1
fi 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 # Step 1: Create application directory
echo "" echo ""
echo -e "${BLUE}Step 1: Setting up application directory...${NC}" echo -e "${BLUE}Step 1: Setting up application directory...${NC}"
@@ -65,48 +174,76 @@ fi
cd $APP_DIR 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 ""
echo -e "${BLUE}Step 3: Checking Docker network...${NC}" echo -e "${BLUE}Step 4: Checking Docker network...${NC}"
if ! docker network ls | grep -q "caddy_default"; then if ! docker network ls | grep -q "caddy_network"; then
print_warning "Caddy network not found. Creating..." print_warning "Caddy network not found. Creating..."
docker network create caddy_default docker network create caddy_network
print_status "Created caddy_default network" print_status "Created caddy_network network"
else else
print_status "Caddy network exists" print_status "Caddy network exists"
fi fi
# Step 4: Show Caddy configuration to add # Step 5: Show Caddy configuration to add
echo "" 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 # 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" print_status "Caddy configuration already exists in Caddyfile"
else 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 ""
echo -e "${YELLOW}# -------------------------" echo -e "${YELLOW}# -------------------------"
echo "# Traceability Matrix Proxy" echo "# Traceability Matrix Proxy"
echo "# -------------------------" echo "# -------------------------"
echo "Traceability.nabd-co.com {" echo "traceability.nabd-co.com {"
echo " reverse_proxy traceability_web:8088" echo " reverse_proxy traceability_web:8088"
echo " encode gzip" 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 -e "}${NC}"
echo "" echo ""
fi fi
# Step 5: Build and start the application # Step 6: Build and start the application
echo "" 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 down --remove-orphans 2>/dev/null || true
docker compose build --no-cache docker compose build --no-cache
docker compose up -d docker compose up -d
print_status "Application started" print_status "Application started"
# Step 6: Reload Caddy # Step 7: Wait for services to be ready
echo "" 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 cd $CADDY_DIR
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || { docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || {
print_warning "Could not reload Caddy automatically. Restarting container..." 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" print_status "Caddy reloaded"
# Step 7: Health check # Step 9: Health check
echo "" echo ""
echo -e "${BLUE}Step 7: Running health check...${NC}" echo -e "${BLUE}Step 9: Running health check...${NC}"
sleep 5
if docker ps | grep -q "traceability_web"; then SERVICES=("traceability_web" "data_service" "email_service")
print_status "Container is running" ALL_RUNNING=true
for service in "${SERVICES[@]}"; do
if docker ps | grep -q "$service"; then
print_status "$service is running"
else else
print_error "Container failed to start. Check logs with: docker logs traceability_web" 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 exit 1
fi fi
@@ -132,11 +278,22 @@ echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} Deployment Complete!${NC}" echo -e "${GREEN} Deployment Complete!${NC}"
echo -e "${GREEN}============================================${NC}" echo -e "${GREEN}============================================${NC}"
echo "" 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 ""
echo -e "Useful commands:" echo -e "${YELLOW}Data Persistence:${NC}"
echo -e " View logs: ${YELLOW}docker logs -f traceability_web${NC}" echo -e " Data Volume: ${BLUE}$VOLUME_NAME${NC}"
echo -e " Restart: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml restart${NC}" echo -e " Backup Dir: ${BLUE}$BACKUP_DIR${NC}"
echo -e " Stop: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml down${NC}" echo ""
echo -e " Rebuild: ${YELLOW}docker compose -f $APP_DIR/docker-compose.yml up -d --build${NC}" 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 "" echo ""

View File

@@ -5,8 +5,52 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: traceability_web container_name: traceability_web
restart: always restart: always
environment:
- VITE_API_URL=http://data-service:3002
- VITE_EMAIL_SERVICE_URL=http://email-service:3001
networks: networks:
- caddy_network - 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: networks:
caddy_network: caddy_network:

12
email-service/Dockerfile Normal file
View 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
View 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}`);
});

View 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"
}
}

View File

@@ -13,7 +13,12 @@ import TraceabilityMatrixPage from "./pages/TraceabilityMatrixPage";
import ESPIDFHelperPage from "./pages/ESPIDFHelperPage"; import ESPIDFHelperPage from "./pages/ESPIDFHelperPage";
import WorkPackageGraphPage from "./pages/WorkPackageGraphPage"; import WorkPackageGraphPage from "./pages/WorkPackageGraphPage";
import SelectedSensorsPage from "./pages/SelectedSensorsPage"; 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 LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import AdminPage from "./pages/AdminPage";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -27,6 +32,7 @@ const App = () => (
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route <Route
path="/" path="/"
element={ element={
@@ -35,6 +41,14 @@ const App = () => (
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/documentation" path="/documentation"
element={ element={
@@ -83,6 +97,30 @@ const App = () => (
</ProtectedRoute> </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 <Route
path="/alm/:type" path="/alm/:type"
element={ element={

View File

@@ -14,7 +14,8 @@ import {
AlertCircle, AlertCircle,
Server, Server,
RefreshCw, RefreshCw,
Loader2 Loader2,
Download
} from 'lucide-react'; } from 'lucide-react';
import { WorkPackage } from '@/types/traceability'; import { WorkPackage } from '@/types/traceability';
import { parseCSVContent, ParseResult } from '@/lib/csvParser'; import { parseCSVContent, ParseResult } from '@/lib/csvParser';
@@ -22,6 +23,9 @@ import { parseCSVContent, ParseResult } from '@/lib/csvParser';
const STORAGE_KEY = 'traceability_data'; const STORAGE_KEY = 'traceability_data';
const SERVER_URL_KEY = 'traceability_server_url'; const SERVER_URL_KEY = 'traceability_server_url';
// API URL for production deployment
const API_URL = import.meta.env.VITE_API_URL || '';
interface DataUpdateDialogProps { interface DataUpdateDialogProps {
onDataLoaded: (workPackages: WorkPackage[]) => void; onDataLoaded: (workPackages: WorkPackage[]) => void;
onClose?: () => void; onClose?: () => void;
@@ -38,7 +42,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
// Server endpoint config - persisted // Server endpoint config - persisted
const [serverUrl, setServerUrl] = useState(() => 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 // Drag and drop state
@@ -91,13 +95,153 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
if (file) handleFile(file); 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 () => { const handleFetchFromServer = async () => {
if (!serverUrl) { if (!serverUrl) {
setErrors(['Please enter a server endpoint URL']); setErrors(['Please enter a server endpoint URL']);
return; return;
} }
// Save URL for next time
localStorage.setItem(SERVER_URL_KEY, serverUrl); localStorage.setItem(SERVER_URL_KEY, serverUrl);
setIsLoading(true); setIsLoading(true);
@@ -159,7 +303,6 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
typeCounts typeCounts
}); });
} else { } else {
// Try to parse as CSV anyway
addLog(`⚠️ Unknown content-type, attempting CSV parse...`); addLog(`⚠️ Unknown content-type, attempting CSV parse...`);
const text = await response.text(); const text = await response.text();
const result = parseCSVContent(text); const result = parseCSVContent(text);
@@ -179,7 +322,6 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
const handleApply = () => { const handleApply = () => {
if (parseResult?.success && parseResult.workPackages.length > 0) { if (parseResult?.success && parseResult.workPackages.length > 0) {
// Persist to localStorage for other users/sessions
persistData(parseResult.workPackages); persistData(parseResult.workPackages);
onDataLoaded(parseResult.workPackages); onDataLoaded(parseResult.workPackages);
onClose?.(); onClose?.();
@@ -204,7 +346,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
Update Traceability Data Update Traceability Data
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Upload a CSV file or fetch from your server Upload a CSV file, reload from static file, or sync from OpenProject
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -212,15 +354,15 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="upload" className="flex items-center gap-2"> <TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
Upload CSV Upload / Reload
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="server" className="flex items-center gap-2"> <TabsTrigger value="server" className="flex items-center gap-2">
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
Server Fetch Server Sync
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Tab 1: Manual CSV Upload */} {/* Tab 1: Manual CSV Upload / Reload */}
<TabsContent value="upload" className="space-y-4"> <TabsContent value="upload" className="space-y-4">
<div <div
onDrop={handleDrop} onDrop={handleDrop}
@@ -247,45 +389,77 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
)} )}
</p> </p>
</div> </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"> <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> </p>
</TabsContent> </TabsContent>
{/* Tab 2: Server Fetch */} {/* Tab 2: Server Sync */}
<TabsContent value="server" className="space-y-4"> <TabsContent value="server" className="space-y-4">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label htmlFor="serverUrl">Server Endpoint</Label> <Label htmlFor="serverUrl">API Endpoint</Label>
<Input <Input
id="serverUrl" id="serverUrl"
placeholder="/api/traceability or https://your-server.com/api/data" placeholder="/api/traceability or https://your-api.com/api/traceability"
value={serverUrl} value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)} onChange={(e) => setServerUrl(e.target.value)}
/> />
<p className="text-xs text-muted-foreground mt-1"> <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> </p>
</div> </div>
<div className="grid grid-cols-2 gap-2">
<Button <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} onClick={handleFetchFromServer}
disabled={isLoading || !serverUrl} disabled={isLoading || !serverUrl}
className="w-full"
> >
{isLoading ? ( {isLoading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Fetching...</> <><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> </Button>
</div> </div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-xs space-y-2"> <div className="bg-muted/50 rounded-lg p-3 text-xs space-y-2">
<p className="font-medium">Server Setup:</p> <p className="font-medium">How it works:</p>
<ol className="list-decimal ml-4 space-y-1 text-muted-foreground"> <ul className="list-disc ml-4 space-y-1 text-muted-foreground">
<li>Create an endpoint that runs <code>get_traceability.py</code></li> <li><strong>Sync from OpenProject</strong>: Runs the Python script on the server to fetch latest data from OpenProject API</li>
<li>Return the CSV file or JSON with work packages</li> <li><strong>Fetch Existing Data</strong>: Gets the last synced CSV from the server (no OpenProject call)</li>
<li>Example: <code>GET /api/traceability</code> returns CSV</li> </ul>
</ol> <p className="text-muted-foreground mt-2">
Note: Server sync only works in deployed environment with the data-service running.
</p>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -337,7 +511,7 @@ export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProp
{/* Logs */} {/* Logs */}
{logs.length > 0 && ( {logs.length > 0 && (
<details className="text-xs"> <details className="text-xs" open>
<summary className="cursor-pointer text-muted-foreground hover:text-foreground"> <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
View logs ({logs.length} entries) View logs ({logs.length} entries)
</summary> </summary>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -19,9 +19,13 @@ import {
Cpu, Cpu,
Share2, Share2,
Thermometer, Thermometer,
ClipboardList,
Shield,
Wallet,
} from "lucide-react"; } from "lucide-react";
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -37,6 +41,8 @@ import { cn } from "@/lib/utils";
const mainItems = [ const mainItems = [
{ title: "Dashboard", url: "/", icon: LayoutDashboard }, { 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: "Traceability Matrix", url: "/matrix", icon: GitBranch },
{ title: "Work Package Graph", url: "/graph", icon: Share2 }, { title: "Work Package Graph", url: "/graph", icon: Share2 },
{ title: "Documentation", url: "/documentation", icon: BookOpen }, { title: "Documentation", url: "/documentation", icon: BookOpen },
@@ -68,6 +74,7 @@ export function AppSidebar() {
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
const location = useLocation(); const location = useLocation();
const currentPath = location.pathname; const currentPath = location.pathname;
const { isAdmin } = useAuth();
const [almExpanded, setAlmExpanded] = useState( const [almExpanded, setAlmExpanded] = useState(
almItems.some((item) => currentPath.startsWith(item.url)) almItems.some((item) => currentPath.startsWith(item.url))
@@ -120,11 +127,28 @@ export function AppSidebar() {
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </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> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
{/* ALM Items */}
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel <SidebarGroupLabel
className="cursor-pointer flex items-center justify-between text-sidebar-foreground/60 hover:text-sidebar-foreground" className="cursor-pointer flex items-center justify-between text-sidebar-foreground/60 hover:text-sidebar-foreground"

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,28 +1,19 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"; import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { User } from "@/types/auth";
interface User { import { authService } from "@/services/authService";
id: number;
username: string;
email: string;
is_active: boolean;
is_admin: boolean;
created_at: string;
updated_at: string;
}
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
isAdmin: boolean;
login: (username: string, password: string) => Promise<{ success: boolean; message: string }>; login: (username: string, password: string) => Promise<{ success: boolean; message: string }>;
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); 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 { interface AuthProviderProps {
children: ReactNode; children: ReactNode;
} }
@@ -31,51 +22,31 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { const refreshUser = async () => {
// Check for existing session on mount
const storedUser = localStorage.getItem("auth_user");
if (storedUser) {
try { try {
setUser(JSON.parse(storedUser)); const currentUser = await authService.getCurrentUser();
} catch { setUser(currentUser);
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" };
}
} catch (error) { } catch (error) {
console.error("Login error:", error); console.error('Failed to get current user:', error);
return { success: false, message: "Connection error. Please try again." }; 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); setUser(null);
localStorage.removeItem("auth_user");
}; };
return ( return (
@@ -84,8 +55,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
user, user,
isAuthenticated: !!user, isAuthenticated: !!user,
isLoading, isLoading,
isAdmin: user?.role === 'admin',
login, login,
logout, logout,
refreshUser,
}} }}
> >
{children} {children}

View 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
View 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
View 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,
});
};

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { WorkPackage, TraceabilityData, WorkPackageType } from '@/types/traceability'; import { WorkPackage, TraceabilityData, WorkPackageType } from '@/types/traceability';
const STORAGE_KEY = 'traceability_data'; const STORAGE_KEY = 'traceability_data';
const API_URL = import.meta.env.VITE_API_URL || '';
export function useTraceabilityData() { export function useTraceabilityData() {
const [data, setData] = useState<TraceabilityData | null>(null); const [data, setData] = useState<TraceabilityData | null>(null);
@@ -235,12 +236,148 @@ export function useTraceabilityData() {
} }
}, [parseCSV]); }, [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(() => { const refresh = useCallback(() => {
loadData(); // In production with API, use sync. Otherwise reload from CSV.
}, [loadData]); if (API_URL) {
syncFromServer();
} else {
reloadFromCSV();
}
}, [syncFromServer, reloadFromCSV]);
// Update data and persist // Update data and persist
const updateData = useCallback((newData: TraceabilityData) => { const updateData = useCallback((newData: TraceabilityData, additionalLogs?: string[]) => {
setData(newData); setData(newData);
setLastUpdated(newData.lastUpdated); setLastUpdated(newData.lastUpdated);
@@ -250,7 +387,12 @@ export function useTraceabilityData() {
workPackages: newData.workPackages 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(() => { useEffect(() => {
@@ -278,6 +420,7 @@ export function useTraceabilityData() {
lastUpdated, lastUpdated,
refresh, refresh,
reloadFromCSV, reloadFromCSV,
syncFromServer,
groupedByType, groupedByType,
typeCounts, typeCounts,
parseLog, parseLog,

125
src/lib/api.ts Normal file
View 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
View 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
View 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>
);
}

View File

@@ -49,8 +49,18 @@ const typeColors: Record<string, string> = {
}; };
export default function Dashboard() { export default function Dashboard() {
const { data, loading, lastUpdated, refresh, reloadFromCSV, typeCounts, groupedByType, parseLog, setData } = const {
useTraceabilityData(); data,
loading,
lastUpdated,
refresh,
reloadFromCSV,
syncFromServer,
typeCounts,
groupedByType,
parseLog,
setData
} = useTraceabilityData();
const [showDebug, setShowDebug] = useState(false); const [showDebug, setShowDebug] = useState(false);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
@@ -157,7 +167,18 @@ export default function Dashboard() {
Update Data Update Data
</Button> </Button>
<Button variant="outline" onClick={reloadFromCSV} disabled={loading}> <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> </Button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -111,15 +111,13 @@ export default function LoginPage() {
</Card> </Card>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Need access?{" "} Don't have an account?{" "}
<a <Link
href="https://sso.nabd-co.com/register.html" to="/register"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium" className="text-primary hover:underline font-medium"
> >
Request access from administrator Register here
</a> </Link>
</p> </p>
</div> </div>
</div> </div>

View 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
View 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>
);
}

View 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
View 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,
};

View 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,
};

View 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
View 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
View 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
View 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[];
}