feat: data export — CSV export for tasks, users, activities

- Backend: GET /api/export/{tasks,users,activities}?month=YYYY-MM
- Frontend: Export panel on Reports page (CEO/CTO/Manager only)
- API: apiExportCsv helper for browser download
This commit is contained in:
tusuii
2026-02-16 13:26:36 +05:30
parent 0fa2302b26
commit 6aec1445e9
5 changed files with 195 additions and 2 deletions

View File

@@ -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) => {

131
server/routes/export.js Normal file
View 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;

View File

@@ -240,7 +240,7 @@ 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} currentUser={currentUser} onAddUser={handleAddUser} onDeleteUser={handleDeleteUser} />} {displayPage === 'members' && <MembersPage tasks={tasks} users={users} currentUser={currentUser} onAddUser={handleAddUser} onDeleteUser={handleDeleteUser} />}
</div> </div>
</div> </div>

View File

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

View File

@@ -48,6 +48,21 @@ export async function apiDeleteUser(id: string) {
}); });
} }
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');