Compare commits
2 Commits
feature/k8
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aec1445e9 | ||
|
|
0fa2302b26 |
@@ -3,6 +3,7 @@ import cors from 'cors';
|
|||||||
import { initDB } from './db.js';
|
import { initDB } from './db.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import taskRoutes from './routes/tasks.js';
|
import taskRoutes from './routes/tasks.js';
|
||||||
|
import exportRoutes from './routes/export.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -13,6 +14,7 @@ app.use(express.json());
|
|||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/tasks', taskRoutes);
|
app.use('/api/tasks', taskRoutes);
|
||||||
|
app.use('/api/export', exportRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
|
|||||||
@@ -76,4 +76,59 @@ router.get('/users', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/users — Admin-create a new user (manager/cto/ceo)
|
||||||
|
router.post('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, email, password, role, dept } = req.body;
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Name, email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
const avatar = name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase();
|
||||||
|
const colors = ['#818cf8', '#f59e0b', '#34d399', '#f472b6', '#fb923c', '#60a5fa', '#a78bfa'];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO users (id, name, role, email, password_hash, color, avatar, dept) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[id, name, role || 'employee', email, password_hash, color, avatar, dept || '']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ id, name, role: role || 'employee', email, color, avatar, dept: dept || '' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create user error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/users/:id — Delete a user (unassign their tasks first)
|
||||||
|
router.delete('/users/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const [user] = await pool.query('SELECT id FROM users WHERE id = ?', [id]);
|
||||||
|
if (user.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassign tasks assigned to or reported by this user
|
||||||
|
await pool.query('UPDATE tasks SET assignee_id = NULL WHERE assignee_id = ?', [id]);
|
||||||
|
await pool.query('UPDATE tasks SET reporter_id = NULL WHERE reporter_id = ?', [id]);
|
||||||
|
|
||||||
|
// Delete the user (cascading will handle comments, etc. via ON DELETE SET NULL)
|
||||||
|
await pool.query('DELETE FROM users WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
res.json({ success: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete user error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
131
server/routes/export.js
Normal file
131
server/routes/export.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import pool from '../db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Helper: escape CSV field
|
||||||
|
function csvEscape(val) {
|
||||||
|
if (val == null) return '';
|
||||||
|
const str = String(val);
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: convert rows to CSV string
|
||||||
|
function toCsv(headers, rows) {
|
||||||
|
const headerLine = headers.map(csvEscape).join(',');
|
||||||
|
const dataLines = rows.map(row => headers.map(h => csvEscape(row[h])).join(','));
|
||||||
|
return [headerLine, ...dataLines].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: build month filter clause for a date column
|
||||||
|
function monthFilter(column, month) {
|
||||||
|
if (!month || !/^\d{4}-\d{2}$/.test(month)) return { clause: '', params: [] };
|
||||||
|
const [year, mon] = month.split('-');
|
||||||
|
const start = `${year}-${mon}-01`;
|
||||||
|
// Last day of month
|
||||||
|
const nextMonth = parseInt(mon) === 12 ? `${parseInt(year) + 1}-01-01` : `${year}-${String(parseInt(mon) + 1).padStart(2, '0')}-01`;
|
||||||
|
return { clause: ` AND ${column} >= ? AND ${column} < ?`, params: [start, nextMonth] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/export/tasks?month=YYYY-MM
|
||||||
|
router.get('/tasks', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { month } = req.query;
|
||||||
|
const mf = monthFilter('t.due_date', month);
|
||||||
|
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT t.id, t.title, t.description, t.status, t.priority,
|
||||||
|
t.due_date, t.created_at,
|
||||||
|
a.name AS assignee_name, a.email AS assignee_email,
|
||||||
|
r.name AS reporter_name,
|
||||||
|
GROUP_CONCAT(tt.tag SEPARATOR '; ') AS tags
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN users a ON t.assignee_id = a.id
|
||||||
|
LEFT JOIN users r ON t.reporter_id = r.id
|
||||||
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||||
|
WHERE 1=1 ${mf.clause}
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`, mf.params);
|
||||||
|
|
||||||
|
const csv = toCsv(
|
||||||
|
['id', 'title', 'description', 'status', 'priority', 'due_date', 'created_at', 'assignee_name', 'assignee_email', 'reporter_name', 'tags'],
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
|
||||||
|
const filename = month ? `tasks_${month}.csv` : 'tasks_all.csv';
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export tasks error:', err);
|
||||||
|
res.status(500).json({ error: 'Export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/export/users?month=YYYY-MM
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { month } = req.query;
|
||||||
|
const mf = monthFilter('t.due_date', month);
|
||||||
|
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT u.id, u.name, u.email, u.role, u.dept,
|
||||||
|
COUNT(t.id) AS total_tasks,
|
||||||
|
SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS completed_tasks,
|
||||||
|
SUM(CASE WHEN t.status != 'done' AND t.due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_tasks
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN tasks t ON t.assignee_id = u.id ${mf.clause ? 'AND' + mf.clause.replace(' AND', '') : ''}
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.name
|
||||||
|
`, mf.params);
|
||||||
|
|
||||||
|
const csv = toCsv(
|
||||||
|
['id', 'name', 'email', 'role', 'dept', 'total_tasks', 'completed_tasks', 'overdue_tasks'],
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
|
||||||
|
const filename = month ? `users_${month}.csv` : 'users_all.csv';
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export users error:', err);
|
||||||
|
res.status(500).json({ error: 'Export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/export/activities?month=YYYY-MM
|
||||||
|
router.get('/activities', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { month } = req.query;
|
||||||
|
const mf = monthFilter('a.timestamp', month);
|
||||||
|
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT a.id, a.text AS activity, a.timestamp,
|
||||||
|
t.title AS task_title, t.status AS task_status
|
||||||
|
FROM activities a
|
||||||
|
LEFT JOIN tasks t ON a.task_id = t.id
|
||||||
|
WHERE 1=1 ${mf.clause}
|
||||||
|
ORDER BY a.timestamp DESC
|
||||||
|
`, mf.params);
|
||||||
|
|
||||||
|
const csv = toCsv(
|
||||||
|
['id', 'activity', 'timestamp', 'task_title', 'task_status'],
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
|
||||||
|
const filename = month ? `activities_${month}.csv` : 'activities_all.csv';
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export activities error:', err);
|
||||||
|
res.status(500).json({ error: 'Export failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
18
src/App.tsx
18
src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency } from './api';
|
import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency, apiCreateUser, apiDeleteUser } from './api';
|
||||||
import type { Task, User, Status } from './data';
|
import type { Task, User, Status } from './data';
|
||||||
import { STATUS_LABELS } from './data';
|
import { STATUS_LABELS } from './data';
|
||||||
import { LoginPage } from './Login';
|
import { LoginPage } from './Login';
|
||||||
@@ -186,6 +186,18 @@ export default function App() {
|
|||||||
} catch (err) { console.error('Failed to remove dependency:', err); }
|
} catch (err) { console.error('Failed to remove dependency:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddUser = async (data: { name: string; email: string; password: string; role: string; dept: string }) => {
|
||||||
|
const newUser = await apiCreateUser(data);
|
||||||
|
setUsers(prev => [...prev, newUser]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (id: string) => {
|
||||||
|
await apiDeleteUser(id);
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== id));
|
||||||
|
// Unassign tasks locally too
|
||||||
|
setTasks(prev => prev.map(t => t.assignee === id ? { ...t, assignee: '' } : t).map(t => t.reporter === id ? { ...t, reporter: '' } : t));
|
||||||
|
};
|
||||||
|
|
||||||
const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
|
const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
|
||||||
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
|
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
|
||||||
|
|
||||||
@@ -228,8 +240,8 @@ export default function App() {
|
|||||||
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} users={users} />
|
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} users={users} />
|
||||||
)}
|
)}
|
||||||
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} users={users} />}
|
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} users={users} />}
|
||||||
{displayPage === 'reports' && <ReportsPage tasks={tasks} users={users} />}
|
{displayPage === 'reports' && <ReportsPage tasks={tasks} users={users} currentUser={currentUser} />}
|
||||||
{displayPage === 'members' && <MembersPage tasks={tasks} users={users} />}
|
{displayPage === 'members' && <MembersPage tasks={tasks} users={users} currentUser={currentUser} onAddUser={handleAddUser} onDeleteUser={handleDeleteUser} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,18 +42,55 @@ export function TeamTasksPage({ tasks, users }: { tasks: Task[]; currentUser: Us
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] }) {
|
export function MembersPage({ tasks, users, currentUser, onAddUser, onDeleteUser }: { tasks: Task[]; users: User[]; currentUser: User; onAddUser: (data: { name: string; email: string; password: string; role: string; dept: string }) => Promise<void>; onDeleteUser: (id: string) => Promise<void> }) {
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
const [addForm, setAddForm] = useState({ name: '', email: '', password: '', role: 'employee', dept: '' });
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
const canManage = ['ceo', 'cto', 'manager'].includes(currentUser.role);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!addForm.name.trim() || !addForm.email.trim() || !addForm.password.trim()) {
|
||||||
|
setAddError('Name, email and password are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddLoading(true);
|
||||||
|
setAddError('');
|
||||||
|
try {
|
||||||
|
await onAddUser(addForm);
|
||||||
|
setShowAdd(false);
|
||||||
|
setAddForm({ name: '', email: '', password: '', role: 'employee', dept: '' });
|
||||||
|
} catch (err: any) {
|
||||||
|
setAddError(err.message || 'Failed to add employee');
|
||||||
|
} finally {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await onDeleteUser(id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="members-page">
|
<div className="members-page">
|
||||||
<div className="members-header">
|
<div className="members-header">
|
||||||
<h2>Team Members</h2>
|
<h2>Team Members</h2>
|
||||||
<button className="btn-ghost" onClick={() => setShowInvite(true)}>+ Invite Member</button>
|
{canManage && <button className="btn-primary" onClick={() => setShowAdd(true)}>+ Add Employee</button>}
|
||||||
</div>
|
</div>
|
||||||
<table className="members-table">
|
<table className="members-table">
|
||||||
<thead><tr><th>Avatar</th><th>Full Name</th><th>Role</th><th>Dept</th><th>Assigned</th><th>Done</th><th>Active</th></tr></thead>
|
<thead><tr><th>Avatar</th><th>Full Name</th><th>Role</th><th>Dept</th><th>Assigned</th><th>Done</th><th>Active</th>{canManage && <th>Actions</th>}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map(u => {
|
{users.map(u => {
|
||||||
const ut = tasks.filter(t => t.assignee === u.id);
|
const ut = tasks.filter(t => t.assignee === u.id);
|
||||||
@@ -70,9 +107,16 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
|
|||||||
<td>{ut.length}</td>
|
<td>{ut.length}</td>
|
||||||
<td>{done}</td>
|
<td>{done}</td>
|
||||||
<td>{active}</td>
|
<td>{active}</td>
|
||||||
|
{canManage && (
|
||||||
|
<td onClick={e => e.stopPropagation()}>
|
||||||
|
{u.id !== currentUser.id && (
|
||||||
|
<button className="btn-danger-sm" onClick={() => setConfirmDelete(u.id)} title="Delete employee">🗑</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
{expanded === u.id && (
|
{expanded === u.id && (
|
||||||
<tr><td colSpan={7}>
|
<tr><td colSpan={canManage ? 8 : 7}>
|
||||||
<div className="member-expand">
|
<div className="member-expand">
|
||||||
{ut.map(t => (
|
{ut.map(t => (
|
||||||
<div key={t.id} className="team-task-row">
|
<div key={t.id} className="team-task-row">
|
||||||
@@ -91,18 +135,45 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{showInvite && (
|
{/* Add Employee Modal */}
|
||||||
<div className="modal-backdrop" onClick={() => setShowInvite(false)}>
|
{showAdd && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setShowAdd(false)}>
|
||||||
<div className="modal invite-modal" onClick={e => e.stopPropagation()}>
|
<div className="modal invite-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header"><h2>Invite Member</h2><button className="drawer-close" onClick={() => setShowInvite(false)}>✕</button></div>
|
<div className="modal-header"><h2>Add Employee</h2><button className="drawer-close" onClick={() => setShowAdd(false)}>✕</button></div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="modal-field"><label>Email</label><input className="modal-input" placeholder="member@company.io" /></div>
|
<div className="modal-field"><label>Full Name *</label><input className="modal-input" placeholder="John Doe" value={addForm.name} onChange={e => { setAddForm(f => ({ ...f, name: e.target.value })); setAddError(''); }} /></div>
|
||||||
<div className="modal-field">
|
<div className="modal-field"><label>Email *</label><input className="modal-input" type="email" placeholder="john@company.io" value={addForm.email} onChange={e => { setAddForm(f => ({ ...f, email: e.target.value })); setAddError(''); }} /></div>
|
||||||
<label>Role</label>
|
<div className="modal-field"><label>Password *</label><input className="modal-input" type="password" placeholder="••••••••" value={addForm.password} onChange={e => { setAddForm(f => ({ ...f, password: e.target.value })); setAddError(''); }} /></div>
|
||||||
<select className="modal-input"><option value="employee">Employee</option><option value="tech_lead">Tech Lead</option><option value="scrum_master">Scrum Master</option><option value="product_owner">Product Owner</option><option value="designer">Designer</option><option value="qa">QA Engineer</option><option value="manager">Manager</option><option value="cto">CTO</option><option value="ceo">CEO</option></select>
|
<div className="modal-field"><label>Role</label>
|
||||||
|
<select className="modal-input" value={addForm.role} onChange={e => setAddForm(f => ({ ...f, role: e.target.value }))}>
|
||||||
|
<option value="employee">Employee</option><option value="tech_lead">Tech Lead</option><option value="scrum_master">Scrum Master</option>
|
||||||
|
<option value="product_owner">Product Owner</option><option value="designer">Designer</option><option value="qa">QA Engineer</option>
|
||||||
|
<option value="manager">Manager</option><option value="cto">CTO</option><option value="ceo">CEO</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field"><label>Department</label><input className="modal-input" placeholder="e.g. Engineering" value={addForm.dept} onChange={e => setAddForm(f => ({ ...f, dept: e.target.value }))} /></div>
|
||||||
|
{addError && <p style={{ color: '#ef4444', fontSize: 12, margin: '4px 0 0' }}>{addError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-ghost" onClick={() => setShowAdd(false)}>Cancel</button>
|
||||||
|
<button className="btn-primary" onClick={handleAdd} disabled={addLoading}>{addLoading ? 'Adding...' : 'Add Employee'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer"><button className="btn-ghost" onClick={() => setShowInvite(false)}>Cancel</button><button className="btn-primary" onClick={() => setShowInvite(false)}>Send Invite</button></div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{confirmDelete && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setConfirmDelete(null)}>
|
||||||
|
<div className="modal invite-modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||||
|
<div className="modal-header"><h2>Confirm Delete</h2><button className="drawer-close" onClick={() => setConfirmDelete(null)}>✕</button></div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p style={{ color: '#cbd5e1', fontSize: 14 }}>Are you sure you want to delete <strong>{users.find(u => u.id === confirmDelete)?.name}</strong>? Their tasks will be unassigned.</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-ghost" onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||||
|
<button className="btn-danger" onClick={() => handleDelete(confirmDelete)} disabled={deleteLoading}>{deleteLoading ? 'Deleting...' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import type { Task, User } from './data';
|
import type { Task, User } from './data';
|
||||||
import { STATUS_COLORS, PRIORITY_COLORS } from './data';
|
import { STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||||
|
import { apiExportCsv } from './api';
|
||||||
import { BarChart, Bar, PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { BarChart, Bar, PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
@@ -8,7 +10,12 @@ const tooltipStyle = {
|
|||||||
labelStyle: { color: '#94a3b8' },
|
labelStyle: { color: '#94a3b8' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ReportsPage({ tasks, users }: { tasks: Task[]; users: User[] }) {
|
export function ReportsPage({ tasks, users, currentUser }: { tasks: Task[]; users: User[]; currentUser: User }) {
|
||||||
|
const [exportType, setExportType] = useState<'tasks' | 'users' | 'activities'>('tasks');
|
||||||
|
const [exportMonth, setExportMonth] = useState('');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const canExport = ['ceo', 'cto', 'manager'].includes(currentUser.role);
|
||||||
const total = tasks.length;
|
const total = tasks.length;
|
||||||
const completed = tasks.filter(t => t.status === 'done').length;
|
const completed = tasks.filter(t => t.status === 'done').length;
|
||||||
const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
|
const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
|
||||||
@@ -113,6 +120,44 @@ export function ReportsPage({ tasks, users }: { tasks: Task[]; users: User[] })
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canExport && (
|
||||||
|
<div className="chart-card" style={{ marginTop: 24 }}>
|
||||||
|
<div className="chart-card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span>📥</span> Export Data
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<div className="modal-field" style={{ margin: 0 }}>
|
||||||
|
<label style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>Dataset</label>
|
||||||
|
<select className="modal-input" style={{ width: 160 }} value={exportType} onChange={e => setExportType(e.target.value as 'tasks' | 'users' | 'activities')}>
|
||||||
|
<option value="tasks">Tasks</option>
|
||||||
|
<option value="users">Users & Workload</option>
|
||||||
|
<option value="activities">Activity Log</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field" style={{ margin: 0 }}>
|
||||||
|
<label style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>Month (optional)</label>
|
||||||
|
<input className="modal-input" type="month" style={{ width: 160 }} value={exportMonth} onChange={e => setExportMonth(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await apiExportCsv(exportType, exportMonth || undefined);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exporting ? 'Exporting...' : '⬇ Download CSV'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/api.ts
30
src/api.ts
@@ -33,6 +33,36 @@ export async function apiFetchUsers() {
|
|||||||
return request('/auth/users');
|
return request('/auth/users');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiCreateUser(data: {
|
||||||
|
name: string; email: string; password: string; role?: string; dept?: string;
|
||||||
|
}) {
|
||||||
|
return request('/auth/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDeleteUser(id: string) {
|
||||||
|
return request(`/auth/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiExportCsv(type: 'tasks' | 'users' | 'activities', month?: string) {
|
||||||
|
const params = month ? `?month=${month}` : '';
|
||||||
|
const res = await fetch(`${API_BASE}/export/${type}${params}`);
|
||||||
|
if (!res.ok) throw new Error('Export failed');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = month ? `${type}_${month}.csv` : `${type}_all.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
export async function apiFetchTasks() {
|
export async function apiFetchTasks() {
|
||||||
return request('/tasks');
|
return request('/tasks');
|
||||||
|
|||||||
@@ -1655,7 +1655,44 @@ body {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DEPENDENCIES */
|
.btn-primary:disabled,
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: #ef4444;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-sm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-sm:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
.dep-unresolved-badge {
|
.dep-unresolved-badge {
|
||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(239, 68, 68, 0.15);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
|
|||||||
Reference in New Issue
Block a user