v1.2.0: Responsive Design & Bug Fixes - Implemented responsive dropdown menu for course actions - Fixed trainer assignment dropdown population - Resolved available trainees API routing conflict - Enhanced mobile responsiveness and accessibility - Fixed setup page redirect issues - Improved code quality and ESLint compliance - Added comprehensive logging and debugging - Updated version.txt with detailed changelog
This commit is contained in:
parent
8a71d336e5
commit
95f4377170
24 changed files with 1807 additions and 978 deletions
|
|
@ -171,8 +171,8 @@ cd ..
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=courseworx
|
DB_NAME=courseworx
|
||||||
DB_USER=your_postgres_username
|
DB_USER=mabdalla
|
||||||
DB_PASSWORD=your_postgres_password
|
DB_PASSWORD=7ouDa-123q
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
JWT_SECRET=your_super_secret_jwt_key_here_make_it_long_and_random
|
JWT_SECRET=your_super_secret_jwt_key_here_make_it_long_and_random
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,109 @@ const generateToken = (userId) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @route GET /api/auth/setup-status
|
||||||
|
// @desc Check if system setup is required
|
||||||
|
// @access Public
|
||||||
|
router.get('/setup-status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const superAdminCount = await User.count({ where: { role: 'super_admin' } });
|
||||||
|
res.json({
|
||||||
|
setupRequired: superAdminCount === 0,
|
||||||
|
superAdminCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup status check error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route POST /api/auth/setup
|
||||||
|
// @desc First-time setup - Create super admin (Public)
|
||||||
|
// @access Public (only when no SA exists)
|
||||||
|
router.post('/setup', [
|
||||||
|
body('firstName').isLength({ min: 2, max: 50 }),
|
||||||
|
body('lastName').isLength({ min: 2, max: 50 }),
|
||||||
|
body('email').isEmail().normalizeEmail(),
|
||||||
|
body('password').isLength({ min: 6 }),
|
||||||
|
body('phone').notEmpty().withMessage('Phone number is required'),
|
||||||
|
body('isActive').optional().isBoolean()
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if setup is already completed
|
||||||
|
const superAdminCount = await User.count({ where: { role: 'super_admin' } });
|
||||||
|
if (superAdminCount > 0) {
|
||||||
|
return res.status(400).json({ error: 'System setup already completed.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, email, password, phone } = req.body;
|
||||||
|
|
||||||
|
// Check if user already exists (by email or phone)
|
||||||
|
const existingUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
[require('sequelize').Op.or]: [
|
||||||
|
{ email },
|
||||||
|
{ phone }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
if (existingUser.email === email) {
|
||||||
|
return res.status(400).json({ error: 'User with this email already exists.' });
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'User with this phone number already exists.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the first super admin
|
||||||
|
const superAdmin = await User.create({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
role: 'super_admin',
|
||||||
|
phone,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Super Admin created during setup:', {
|
||||||
|
id: superAdmin.id,
|
||||||
|
email: superAdmin.email,
|
||||||
|
phone: superAdmin.phone,
|
||||||
|
role: superAdmin.role
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate token for immediate login
|
||||||
|
const token = generateToken(superAdmin.id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'System setup completed successfully. Super Admin account created.',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: superAdmin.id,
|
||||||
|
firstName: superAdmin.firstName,
|
||||||
|
lastName: superAdmin.lastName,
|
||||||
|
email: superAdmin.email,
|
||||||
|
phone: superAdmin.phone,
|
||||||
|
role: superAdmin.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// @route POST /api/auth/login
|
// @route POST /api/auth/login
|
||||||
// @desc Login user
|
// @desc Login user
|
||||||
// @access Public
|
// @access Public
|
||||||
router.post('/login', [
|
router.post('/login', [
|
||||||
body('email').isEmail().normalizeEmail(),
|
body('identifier').notEmpty().withMessage('Email or phone number is required'),
|
||||||
body('password').isLength({ min: 6 })
|
body('password').isLength({ min: 6 })
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -27,10 +125,20 @@ router.post('/login', [
|
||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = req.body;
|
const { identifier, password } = req.body;
|
||||||
|
|
||||||
const user = await User.findOne({ where: { email } });
|
// Find user by email or phone
|
||||||
console.log('Login attempt for email:', email, 'User found:', !!user, 'User active:', user?.isActive);
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
[require('sequelize').Op.or]: [
|
||||||
|
{ email: identifier },
|
||||||
|
{ phone: identifier }
|
||||||
|
],
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Login attempt for identifier:', identifier, 'User found:', !!user, 'User active:', user?.isActive);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials or account inactive.' });
|
return res.status(401).json({ error: 'Invalid credentials or account inactive.' });
|
||||||
|
|
@ -55,9 +163,9 @@ router.post('/login', [
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
phone: user.phone,
|
|
||||||
requiresPasswordChange: user.requiresPasswordChange
|
requiresPasswordChange: user.requiresPasswordChange
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -77,7 +185,8 @@ router.post('/register', [
|
||||||
body('lastName').isLength({ min: 2, max: 50 }),
|
body('lastName').isLength({ min: 2, max: 50 }),
|
||||||
body('email').isEmail().normalizeEmail(),
|
body('email').isEmail().normalizeEmail(),
|
||||||
body('password').isLength({ min: 6 }),
|
body('password').isLength({ min: 6 }),
|
||||||
body('role').isIn(['super_admin', 'trainer', 'trainee'])
|
body('role').isIn(['super_admin', 'trainer', 'trainee']),
|
||||||
|
body('phone').notEmpty().withMessage('Phone number is required')
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|
@ -85,7 +194,7 @@ router.post('/register', [
|
||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { firstName, lastName, email, password, role, phone } = req.body;
|
const { firstName, lastName, email, password, role, phone, isActive = true } = req.body;
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await User.findOne({ where: { email } });
|
const existingUser = await User.findOne({ where: { email } });
|
||||||
|
|
@ -93,13 +202,22 @@ router.post('/register', [
|
||||||
return res.status(400).json({ error: 'User with this email already exists.' });
|
return res.status(400).json({ error: 'User with this email already exists.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent creating another super admin if not during setup
|
||||||
|
if (role === 'super_admin') {
|
||||||
|
const superAdminCount = await User.count({ where: { role: 'super_admin' } });
|
||||||
|
if (superAdminCount > 0) {
|
||||||
|
return res.status(400).json({ error: 'Super Admin account already exists. Cannot create another one.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await User.create({
|
const user = await User.create({
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
role,
|
role,
|
||||||
phone
|
phone,
|
||||||
|
isActive
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('User created successfully:', {
|
console.log('User created successfully:', {
|
||||||
|
|
@ -117,7 +235,8 @@ router.post('/register', [
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
phone: user.phone
|
phone: user.phone,
|
||||||
|
isActive: user.isActive
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -156,7 +275,7 @@ router.put('/profile', [
|
||||||
auth,
|
auth,
|
||||||
body('firstName').optional().isLength({ min: 2, max: 50 }),
|
body('firstName').optional().isLength({ min: 2, max: 50 }),
|
||||||
body('lastName').optional().isLength({ min: 2, max: 50 }),
|
body('lastName').optional().isLength({ min: 2, max: 50 }),
|
||||||
body('phone').optional().isMobilePhone()
|
body('phone').optional()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|
@ -265,4 +384,114 @@ router.put('/first-password-change', [
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @route POST /api/auth/trainee-login
|
||||||
|
// @desc Login trainee with phone or email
|
||||||
|
// @access Public
|
||||||
|
router.post('/trainee-login', [
|
||||||
|
body('identifier').notEmpty().withMessage('Phone or email is required'),
|
||||||
|
body('password').isLength({ min: 6 })
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { identifier, password } = req.body;
|
||||||
|
|
||||||
|
// Find user by email or phone
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
[require('sequelize').Op.or]: [
|
||||||
|
{ email: identifier },
|
||||||
|
{ phone: identifier }
|
||||||
|
],
|
||||||
|
role: 'trainee',
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials or account inactive.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await user.comparePassword(password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await user.update({ lastLogin: new Date() });
|
||||||
|
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
|
role: user.role,
|
||||||
|
avatar: user.avatar,
|
||||||
|
requiresPasswordChange: user.requiresPasswordChange
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Trainee login error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route POST /api/auth/check-enrollment
|
||||||
|
// @desc Check if trainee is enrolled in any courses
|
||||||
|
// @access Private (Trainee)
|
||||||
|
router.post('/check-enrollment', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user.role !== 'trainee') {
|
||||||
|
return res.status(403).json({ error: 'Only trainees can check enrollment.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Enrollment, Course } = require('../models');
|
||||||
|
|
||||||
|
const enrollments = await Enrollment.findAll({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
status: ['active', 'pending']
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Course,
|
||||||
|
as: 'course',
|
||||||
|
attributes: ['id', 'title', 'thumbnail', 'description'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'trainer',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['enrolledAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hasEnrollments: enrollments.length > 0,
|
||||||
|
enrollments: enrollments.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
status: e.status,
|
||||||
|
progress: e.progress,
|
||||||
|
enrolledAt: e.enrolledAt,
|
||||||
|
course: e.course
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check enrollment error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
@ -437,6 +437,8 @@ router.put('/:id/assign-trainer', [
|
||||||
// @access Private (Super Admin)
|
// @access Private (Super Admin)
|
||||||
router.get('/trainers/available', auth, requireSuperAdmin, async (req, res) => {
|
router.get('/trainers/available', auth, requireSuperAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('Available trainers endpoint called by user:', req.user.id, req.user.role);
|
||||||
|
|
||||||
const trainers = await User.findAll({
|
const trainers = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
role: 'trainer',
|
role: 'trainer',
|
||||||
|
|
@ -446,6 +448,8 @@ router.get('/trainers/available', auth, requireSuperAdmin, async (req, res) => {
|
||||||
order: [['firstName', 'ASC'], ['lastName', 'ASC']]
|
order: [['firstName', 'ASC'], ['lastName', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Available trainers found:', trainers.length, trainers.map(t => ({ id: t.id, name: `${t.firstName} ${t.lastName}`, email: t.email })));
|
||||||
|
|
||||||
res.json({ trainers });
|
res.json({ trainers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get available trainers error:', error);
|
console.error('Get available trainers error:', error);
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,122 @@ router.get('/my', auth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @route GET /api/enrollments/course/:courseId/trainees
|
||||||
|
// @desc Get all trainees enrolled in a specific course
|
||||||
|
// @access Private (Course trainer or Super Admin)
|
||||||
|
router.get('/course/:courseId/trainees', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const { status, page = 1, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const whereClause = { courseId };
|
||||||
|
|
||||||
|
if (status) whereClause.status = status;
|
||||||
|
|
||||||
|
// Check if course exists and user has permission
|
||||||
|
const course = await Course.findByPk(courseId);
|
||||||
|
if (!course) {
|
||||||
|
return res.status(404).json({ error: 'Course not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is trainer of this course or super admin
|
||||||
|
if (req.user.role !== 'super_admin' && course.trainerId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to view trainees for this course.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows: enrollments } = await Enrollment.findAndCountAll({
|
||||||
|
where: whereClause,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'email', 'phone', 'avatar'],
|
||||||
|
where: { role: 'trainee' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['enrolledAt', 'DESC']],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
trainees: enrollments.map(e => ({
|
||||||
|
...e.user.toJSON(),
|
||||||
|
enrollmentId: e.id,
|
||||||
|
status: e.status,
|
||||||
|
progress: e.progress,
|
||||||
|
enrolledAt: e.enrolledAt,
|
||||||
|
completedAt: e.completedAt
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
totalItems: count,
|
||||||
|
itemsPerPage: parseInt(limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get course trainees error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route GET /api/enrollments/available-trainees
|
||||||
|
// @desc Get available trainees for course enrollment
|
||||||
|
// @access Private (Trainer or Super Admin)
|
||||||
|
router.get('/available-trainees', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId, search, page = 1, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const whereClause = { role: 'trainee', isActive: true };
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause[require('sequelize').Op.or] = [
|
||||||
|
{ firstName: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ lastName: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ email: { [require('sequelize').Op.iLike]: `%${search}%` } },
|
||||||
|
{ phone: { [require('sequelize').Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all trainees
|
||||||
|
const { count, rows: trainees } = await User.findAndCountAll({
|
||||||
|
where: whereClause,
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'email', 'phone', 'avatar'],
|
||||||
|
order: [['firstName', 'ASC']],
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
});
|
||||||
|
|
||||||
|
// If courseId is provided, filter out already enrolled trainees
|
||||||
|
let availableTrainees = trainees;
|
||||||
|
if (courseId) {
|
||||||
|
const enrolledTraineeIds = await Enrollment.findAll({
|
||||||
|
where: { courseId },
|
||||||
|
attributes: ['userId']
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrolledIds = enrolledTraineeIds.map(e => e.userId);
|
||||||
|
availableTrainees = trainees.filter(t => !enrolledIds.includes(t.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
trainees: availableTrainees,
|
||||||
|
pagination: {
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
totalItems: count,
|
||||||
|
itemsPerPage: parseInt(limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get available trainees error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// @route GET /api/enrollments/:id
|
// @route GET /api/enrollments/:id
|
||||||
// @desc Get enrollment by ID
|
// @desc Get enrollment by ID
|
||||||
// @access Private
|
// @access Private
|
||||||
|
|
@ -548,4 +664,206 @@ router.get('/stats/overview', auth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @route POST /api/enrollments/bulk
|
||||||
|
// @desc Bulk enroll trainees to a course (Trainer only)
|
||||||
|
// @access Private (Trainer or Super Admin)
|
||||||
|
router.post('/bulk', [
|
||||||
|
auth,
|
||||||
|
body('courseId').isUUID(),
|
||||||
|
body('traineeIds').isArray({ min: 1 }),
|
||||||
|
body('traineeIds.*').isUUID(),
|
||||||
|
body('status').optional().isIn(['pending', 'active']),
|
||||||
|
body('notes').optional().isLength({ max: 1000 })
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { courseId, traineeIds, status = 'active', notes } = req.body;
|
||||||
|
|
||||||
|
// Check if course exists and user has permission
|
||||||
|
const course = await Course.findByPk(courseId);
|
||||||
|
if (!course) {
|
||||||
|
return res.status(404).json({ error: 'Course not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is trainer of this course or super admin
|
||||||
|
if (req.user.role !== 'super_admin' && course.trainerId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to enroll trainees to this course.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check course capacity
|
||||||
|
if (course.maxStudents) {
|
||||||
|
const enrolledCount = await Enrollment.count({
|
||||||
|
where: { courseId, status: ['active', 'pending'] }
|
||||||
|
});
|
||||||
|
const remainingCapacity = course.maxStudents - enrolledCount;
|
||||||
|
if (traineeIds.length > remainingCapacity) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Course capacity exceeded. Only ${remainingCapacity} more trainees can be enrolled.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing trainees to avoid duplicates
|
||||||
|
const existingEnrollments = await Enrollment.findAll({
|
||||||
|
where: {
|
||||||
|
courseId,
|
||||||
|
userId: traineeIds
|
||||||
|
},
|
||||||
|
attributes: ['userId']
|
||||||
|
});
|
||||||
|
const existingTraineeIds = existingEnrollments.map(e => e.userId);
|
||||||
|
|
||||||
|
// Filter out already enrolled trainees
|
||||||
|
const newTraineeIds = traineeIds.filter(id => !existingTraineeIds.includes(id));
|
||||||
|
|
||||||
|
if (newTraineeIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'All selected trainees are already enrolled in this course.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enrollments
|
||||||
|
const enrollments = await Promise.all(
|
||||||
|
newTraineeIds.map(traineeId =>
|
||||||
|
Enrollment.create({
|
||||||
|
userId: traineeId,
|
||||||
|
courseId,
|
||||||
|
status,
|
||||||
|
paymentStatus: course.price > 0 ? 'pending' : 'paid',
|
||||||
|
paymentAmount: course.price,
|
||||||
|
notes
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get enrollment details with user and course info
|
||||||
|
const enrollmentsWithDetails = await Enrollment.findAll({
|
||||||
|
where: { id: enrollments.map(e => e.id) },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Course,
|
||||||
|
as: 'course',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'trainer',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'email', 'phone']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: `Successfully enrolled ${enrollments.length} trainees to the course.`,
|
||||||
|
enrollments: enrollmentsWithDetails,
|
||||||
|
skipped: existingTraineeIds.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bulk enrollment error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route POST /api/enrollments/assign
|
||||||
|
// @desc Assign a single trainee to a course (Trainer only)
|
||||||
|
// @access Private (Trainer or Super Admin)
|
||||||
|
router.post('/assign', [
|
||||||
|
auth,
|
||||||
|
body('courseId').isUUID(),
|
||||||
|
body('traineeId').isUUID(),
|
||||||
|
body('status').optional().isIn(['pending', 'active']),
|
||||||
|
body('notes').optional().isLength({ max: 1000 })
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { courseId, traineeId, status = 'active', notes } = req.body;
|
||||||
|
|
||||||
|
// Check if course exists and user has permission
|
||||||
|
const course = await Course.findByPk(courseId);
|
||||||
|
if (!course) {
|
||||||
|
return res.status(404).json({ error: 'Course not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is trainer of this course or super admin
|
||||||
|
if (req.user.role !== 'super_admin' && course.trainerId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to assign trainees to this course.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trainee exists
|
||||||
|
const trainee = await User.findByPk(traineeId);
|
||||||
|
if (!trainee || trainee.role !== 'trainee') {
|
||||||
|
return res.status(404).json({ error: 'Trainee not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already enrolled
|
||||||
|
const existingEnrollment = await Enrollment.findOne({
|
||||||
|
where: { userId: traineeId, courseId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEnrollment) {
|
||||||
|
return res.status(400).json({ error: 'Trainee is already enrolled in this course.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check course capacity
|
||||||
|
if (course.maxStudents) {
|
||||||
|
const enrolledCount = await Enrollment.count({
|
||||||
|
where: { courseId, status: ['active', 'pending'] }
|
||||||
|
});
|
||||||
|
if (enrolledCount >= course.maxStudents) {
|
||||||
|
return res.status(400).json({ error: 'Course is at maximum capacity.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrollment = await Enrollment.create({
|
||||||
|
userId: traineeId,
|
||||||
|
courseId,
|
||||||
|
status,
|
||||||
|
paymentStatus: course.price > 0 ? 'pending' : 'paid',
|
||||||
|
paymentAmount: course.price,
|
||||||
|
notes
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrollmentWithDetails = await Enrollment.findByPk(enrollment.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Course,
|
||||||
|
as: 'course',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'trainer',
|
||||||
|
attributes: ['id', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'firstName', 'lastName', 'email', 'phone']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Trainee successfully assigned to course.',
|
||||||
|
enrollment: enrollmentWithDetails
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Assign trainee error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
@ -1,71 +1,25 @@
|
||||||
const { sequelize } = require('../config/database');
|
const { sequelize } = require('../config/database');
|
||||||
// Import all models to ensure they are registered with Sequelize
|
// Import all models to ensure they are registered with Sequelize
|
||||||
require('../models');
|
require('../models');
|
||||||
const { User } = require('../models');
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const setupDatabase = async () => {
|
const setupDatabase = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Setting up database...');
|
console.log('🔄 Setting up clean database...');
|
||||||
|
|
||||||
// Test database connection
|
// Test database connection
|
||||||
await sequelize.authenticate();
|
await sequelize.authenticate();
|
||||||
console.log('✅ Database connection established.');
|
console.log('✅ Database connection established.');
|
||||||
|
|
||||||
// Sync database with force to recreate all tables
|
// Sync database with force to recreate all tables (clean slate)
|
||||||
await sequelize.sync({ force: true });
|
await sequelize.sync({ force: true });
|
||||||
console.log('✅ Database synchronized successfully.');
|
console.log('✅ Database synchronized successfully - all tables recreated.');
|
||||||
|
|
||||||
// Create super admin user
|
console.log('\n🎉 Clean database setup completed successfully!');
|
||||||
const superAdmin = await User.create({
|
console.log('\n📋 Database is now ready for your data:');
|
||||||
firstName: 'Super',
|
console.log('- All tables have been recreated');
|
||||||
lastName: 'Admin',
|
console.log('- No demo users exist');
|
||||||
email: 'admin@courseworx.com',
|
console.log('- Ready for fresh data input');
|
||||||
password: 'admin123',
|
|
||||||
role: 'super_admin',
|
|
||||||
phone: '+1234567890',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Super admin user created successfully.');
|
|
||||||
console.log('📧 Email: admin@courseworx.com');
|
|
||||||
console.log('🔑 Password: admin123');
|
|
||||||
|
|
||||||
// Create sample trainer
|
|
||||||
const trainer = await User.create({
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Trainer',
|
|
||||||
email: 'trainer@courseworx.com',
|
|
||||||
password: 'trainer123',
|
|
||||||
role: 'trainer',
|
|
||||||
phone: '+1234567891',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Sample trainer created successfully.');
|
|
||||||
console.log('📧 Email: trainer@courseworx.com');
|
|
||||||
console.log('🔑 Password: trainer123');
|
|
||||||
|
|
||||||
// Create sample trainee
|
|
||||||
const trainee = await User.create({
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Trainee',
|
|
||||||
email: 'trainee@courseworx.com',
|
|
||||||
password: 'trainee123',
|
|
||||||
role: 'trainee',
|
|
||||||
phone: '+1234567892',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Sample trainee created successfully.');
|
|
||||||
console.log('📧 Email: trainee@courseworx.com');
|
|
||||||
console.log('🔑 Password: trainee123');
|
|
||||||
|
|
||||||
console.log('\n🎉 Database setup completed successfully!');
|
|
||||||
console.log('\n📋 Default Users:');
|
|
||||||
console.log('Super Admin: admin@courseworx.com / admin123');
|
|
||||||
console.log('Trainer: trainer@courseworx.com / trainer123');
|
|
||||||
console.log('Trainee: trainee@courseworx.com / trainee123');
|
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ app.use(cors({
|
||||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,17 @@ module.exports = {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:5000',
|
target: 'http://localhost:5000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
logLevel: 'debug',
|
||||||
|
onProxyReq: (proxyReq, req, res) => {
|
||||||
|
// Ensure proper content-type headers
|
||||||
|
if (req.body) {
|
||||||
|
const bodyData = JSON.stringify(req.body);
|
||||||
|
proxyReq.setHeader('Content-Type', 'application/json');
|
||||||
|
proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData));
|
||||||
|
proxyReq.write(bodyData);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,5 @@
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.3.6"
|
"tailwindcss": "^3.3.6"
|
||||||
},
|
}
|
||||||
"proxy": "http://localhost:5000"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import React from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
import TraineeLogin from './pages/TraineeLogin';
|
||||||
|
import Setup from './pages/Setup';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Courses from './pages/Courses';
|
import Courses from './pages/Courses';
|
||||||
import CourseDetail from './pages/CourseDetail';
|
import CourseDetail from './pages/CourseDetail';
|
||||||
|
|
@ -14,15 +16,21 @@ import LoadingSpinner from './components/LoadingSpinner';
|
||||||
import CourseEdit from './pages/CourseEdit';
|
import CourseEdit from './pages/CourseEdit';
|
||||||
import CourseContent from './pages/CourseContent';
|
import CourseContent from './pages/CourseContent';
|
||||||
import CourseContentViewer from './pages/CourseContentViewer';
|
import CourseContentViewer from './pages/CourseContentViewer';
|
||||||
|
import CourseEnrollment from './pages/CourseEnrollment';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
|
||||||
const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading, setupRequired } = useAuth();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If setup is required, redirect to setup
|
||||||
|
if (setupRequired) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
@ -35,13 +43,35 @@ const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppRoutes = () => {
|
const AppRoutes = () => {
|
||||||
const { user } = useAuth();
|
const { user, loading, setupRequired } = useAuth();
|
||||||
|
|
||||||
|
console.log('AppRoutes render - user:', user, 'loading:', loading, 'setupRequired:', setupRequired);
|
||||||
|
|
||||||
|
// Show loading while checking setup and auth status
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setup is required, show setup page
|
||||||
|
if (setupRequired) {
|
||||||
|
console.log('Setup required, showing setup page');
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/setup" element={<Setup />} />
|
||||||
|
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Setup not required, showing normal routes');
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={
|
<Route path="/login" element={
|
||||||
user ? <Navigate to="/dashboard" replace /> : <Login />
|
user ? <Navigate to="/dashboard" replace /> : <Login />
|
||||||
} />
|
} />
|
||||||
|
<Route path="/trainee-login" element={
|
||||||
|
user ? <Navigate to="/dashboard" replace /> : <TraineeLogin />
|
||||||
|
} />
|
||||||
{/* Public homepage route */}
|
{/* Public homepage route */}
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
|
||||||
|
|
@ -65,11 +95,21 @@ const AppRoutes = () => {
|
||||||
<CourseContent />
|
<CourseContent />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/courses/:id/enrollment" element={
|
||||||
|
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
||||||
|
<CourseEnrollment />
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/courses/:id/learn" element={
|
<Route path="/courses/:id/learn" element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<CourseContentViewer />
|
<CourseContentViewer />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/enrollments" element={
|
||||||
|
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
||||||
|
<CourseEnrollment />
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/users/import" element={
|
<Route path="/users/import" element={
|
||||||
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
||||||
<UserImport />
|
<UserImport />
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@ const Layout = () => {
|
||||||
{/* Logo and Navigation */}
|
{/* Logo and Navigation */}
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<img src="/images/cx-logo.png" alt="CourseWorx" className="h-8 w-auto" />
|
<div className="h-8 w-8 rounded-full bg-primary-500 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-bold text-white">CX</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Items */}
|
{/* Navigation Items */}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ const TrainerAssignmentModal = ({ isOpen, onClose, courseId, currentTrainer }) =
|
||||||
() => coursesAPI.getAvailableTrainers(),
|
() => coursesAPI.getAvailableTrainers(),
|
||||||
{
|
{
|
||||||
enabled: isOpen,
|
enabled: isOpen,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log('Available trainers loaded:', data);
|
||||||
|
console.log('Data structure:', JSON.stringify(data, null, 2));
|
||||||
|
console.log('Data.trainers:', data?.trainers);
|
||||||
|
console.log('Data.trainers length:', data?.trainers?.length);
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Trainer loading error:', error);
|
console.error('Trainer loading error:', error);
|
||||||
toast.error('Failed to load trainers');
|
toast.error('Failed to load trainers');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import { authAPI } from '../services/api';
|
import { authAPI } from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
|
@ -15,17 +15,39 @@ export const useAuth = () => {
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [setupRequired, setSetupRequired] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const checkSetupAndAuth = useCallback(async () => {
|
||||||
checkAuth();
|
try {
|
||||||
|
// First check if setup is required
|
||||||
|
const setupResponse = await authAPI.setupStatus();
|
||||||
|
const { setupRequired: needsSetup } = setupResponse.data;
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
setSetupRequired(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setup is not required, check authentication
|
||||||
|
await checkAuth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup and auth check failed:', error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
const response = await authAPI.getCurrentUser();
|
const response = await authAPI.getCurrentUser();
|
||||||
setUser(response.data.user);
|
if (response.data && response.data.user) {
|
||||||
|
setUser(response.data.user);
|
||||||
|
} else {
|
||||||
|
// Token is invalid, remove it
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check failed:', error);
|
console.error('Auth check failed:', error);
|
||||||
|
|
@ -33,12 +55,16 @@ export const AuthProvider = ({ children }) => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const login = async (email, password) => {
|
useEffect(() => {
|
||||||
|
checkSetupAndAuth();
|
||||||
|
}, [checkSetupAndAuth]);
|
||||||
|
|
||||||
|
const login = async (identifier, password) => {
|
||||||
try {
|
try {
|
||||||
console.log('Attempting login with email:', email);
|
console.log('Attempting login with identifier:', identifier);
|
||||||
const response = await authAPI.login(email, password);
|
const response = await authAPI.login(identifier, password);
|
||||||
const { token, user } = response.data;
|
const { token, user } = response.data;
|
||||||
|
|
||||||
console.log('Login successful, user:', user);
|
console.log('Login successful, user:', user);
|
||||||
|
|
@ -91,12 +117,19 @@ export const AuthProvider = ({ children }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = (userData) => {
|
const updateUser = (userData) => {
|
||||||
|
console.log('updateUser called with:', userData);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
// If we have a user, setup is no longer required
|
||||||
|
if (userData) {
|
||||||
|
console.log('Setting setupRequired to false');
|
||||||
|
setSetupRequired(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
|
setupRequired,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from 'react-query';
|
import { useQuery, useMutation, useQueryClient } from 'react-query';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
UserPlusIcon,
|
||||||
|
EllipsisVerticalIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import TrainerAssignmentModal from '../components/TrainerAssignmentModal';
|
import TrainerAssignmentModal from '../components/TrainerAssignmentModal';
|
||||||
|
|
@ -21,7 +23,23 @@ const CourseDetail = () => {
|
||||||
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
|
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
|
||||||
const [enrolling, setEnrolling] = useState(false);
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
const [showTrainerModal, setShowTrainerModal] = useState(false);
|
const [showTrainerModal, setShowTrainerModal] = useState(false);
|
||||||
|
const [showActionsDropdown, setShowActionsDropdown] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setShowActionsDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: course, isLoading, error } = useQuery(
|
const { data: course, isLoading, error } = useQuery(
|
||||||
['course', id],
|
['course', id],
|
||||||
|
|
@ -117,32 +135,79 @@ const CourseDetail = () => {
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
|
||||||
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
|
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
{/* Action Buttons - Responsive Dropdown */}
|
||||||
<Link
|
<div className="relative" ref={dropdownRef}>
|
||||||
to={`/courses/${id}/learn`}
|
<button
|
||||||
className="btn-primary flex items-center shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
onClick={() => setShowActionsDropdown(!showActionsDropdown)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowActionsDropdown(!showActionsDropdown);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
title="Course Actions"
|
||||||
|
aria-label="Course actions menu"
|
||||||
|
aria-expanded={showActionsDropdown}
|
||||||
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<EyeIcon className="h-5 w-5 mr-2" />
|
<EllipsisVerticalIcon className="h-6 w-6" />
|
||||||
View Content
|
</button>
|
||||||
</Link>
|
|
||||||
{(isTrainer || isSuperAdmin) && (
|
{/* Dropdown Menu */}
|
||||||
<Link
|
{showActionsDropdown && (
|
||||||
to={`/courses/${id}/content`}
|
<div className="absolute right-0 mt-2 w-56 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50 transition-all duration-200 ease-out sm:right-0 md:right-0 lg:right-0 xl:right-0">
|
||||||
className="btn-secondary flex items-center"
|
<div className="py-1">
|
||||||
>
|
{/* View Content - Always visible */}
|
||||||
<CogIcon className="h-5 w-5 mr-2" />
|
<Link
|
||||||
Manage Content
|
to={`/courses/${id}/learn`}
|
||||||
</Link>
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-200"
|
||||||
)}
|
onClick={() => setShowActionsDropdown(false)}
|
||||||
{isSuperAdmin && (
|
>
|
||||||
<button
|
<EyeIcon className="h-5 w-5 mr-3 text-gray-400" />
|
||||||
onClick={() => setShowTrainerModal(true)}
|
View Content
|
||||||
className="btn-secondary flex items-center"
|
</Link>
|
||||||
title="Assign Trainer (Super Admin only)"
|
|
||||||
>
|
{/* Manage Content - Trainer/Admin only */}
|
||||||
<UserIcon className="h-4 w-4 mr-2" />
|
{(isTrainer || isSuperAdmin) && (
|
||||||
Assign Trainer
|
<Link
|
||||||
</button>
|
to={`/courses/${id}/content`}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-200"
|
||||||
|
onClick={() => setShowActionsDropdown(false)}
|
||||||
|
>
|
||||||
|
<CogIcon className="h-5 w-5 mr-3 text-gray-400" />
|
||||||
|
Manage Content
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manage Enrollment - Trainer/Admin only */}
|
||||||
|
{(isTrainer || isSuperAdmin) && (
|
||||||
|
<Link
|
||||||
|
to={`/courses/${id}/enrollment`}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-200"
|
||||||
|
onClick={() => setShowActionsDropdown(false)}
|
||||||
|
>
|
||||||
|
<UserPlusIcon className="h-5 w-5 mr-3 text-gray-400" />
|
||||||
|
Manage Enrollment
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assign Trainer - Super Admin only */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionsDropdown(false);
|
||||||
|
setShowTrainerModal(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-200"
|
||||||
|
title="Assign Trainer (Super Admin only)"
|
||||||
|
>
|
||||||
|
<UserIcon className="h-4 w-4 mr-3 text-gray-400" />
|
||||||
|
Assign Trainer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
357
frontend/src/pages/CourseEnrollment.js
Normal file
357
frontend/src/pages/CourseEnrollment.js
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from 'react-query';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { coursesAPI, enrollmentsAPI } from '../services/api';
|
||||||
|
import {
|
||||||
|
UserPlusIcon,
|
||||||
|
UsersIcon,
|
||||||
|
AcademicCapIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
CheckIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationTriangleIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const CourseEnrollment = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { user, isTrainer, isSuperAdmin } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||||
|
const [selectedTrainees, setSelectedTrainees] = useState([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [assigning, setAssigning] = useState(false);
|
||||||
|
|
||||||
|
// Get course details
|
||||||
|
const { data: course, isLoading: courseLoading } = useQuery(
|
||||||
|
['course', id],
|
||||||
|
() => coursesAPI.getById(id),
|
||||||
|
{ enabled: !!id }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get enrolled trainees
|
||||||
|
const { data: enrolledData, isLoading: enrolledLoading } = useQuery(
|
||||||
|
['enrollments', 'course', id, 'trainees'],
|
||||||
|
() => enrollmentsAPI.getCourseTrainees(id),
|
||||||
|
{ enabled: !!id }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get available trainees
|
||||||
|
const { data: availableData, isLoading: availableLoading } = useQuery(
|
||||||
|
['enrollments', 'available-trainees', id],
|
||||||
|
() => enrollmentsAPI.getAvailableTrainees({ courseId: id }),
|
||||||
|
{ enabled: !!id && showAssignModal }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk enrollment mutation
|
||||||
|
const bulkEnrollMutation = useMutation(
|
||||||
|
(data) => enrollmentsAPI.bulkEnroll(data),
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries(['enrollments', 'course', id, 'trainees']);
|
||||||
|
queryClient.invalidateQueries(['enrollments', 'available-trainees', id]);
|
||||||
|
toast.success(data.message);
|
||||||
|
setShowAssignModal(false);
|
||||||
|
setSelectedTrainees([]);
|
||||||
|
setAssigning(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to assign trainees');
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single trainee assignment mutation
|
||||||
|
const assignTraineeMutation = useMutation(
|
||||||
|
(data) => enrollmentsAPI.assignTrainee(data),
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries(['enrollments', 'course', id, 'trainees']);
|
||||||
|
queryClient.invalidateQueries(['enrollments', 'available-trainees', id]);
|
||||||
|
toast.success(data.message);
|
||||||
|
setShowAssignModal(false);
|
||||||
|
setSelectedTrainees([]);
|
||||||
|
setAssigning(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to assign trainee');
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkAssign = async () => {
|
||||||
|
if (selectedTrainees.length === 0) {
|
||||||
|
toast.error('Please select at least one trainee');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssigning(true);
|
||||||
|
bulkEnrollMutation.mutate({
|
||||||
|
courseId: id,
|
||||||
|
traineeIds: selectedTrainees,
|
||||||
|
status: 'active',
|
||||||
|
notes: `Bulk assigned by ${user.firstName} ${user.lastName}`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSingleAssign = async (traineeId) => {
|
||||||
|
setAssigning(true);
|
||||||
|
assignTraineeMutation.mutate({
|
||||||
|
courseId: id,
|
||||||
|
traineeId,
|
||||||
|
status: 'active',
|
||||||
|
notes: `Assigned by ${user.firstName} ${user.lastName}`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTraineeSelection = (traineeId) => {
|
||||||
|
setSelectedTrainees(prev =>
|
||||||
|
prev.includes(traineeId)
|
||||||
|
? prev.filter(id => id !== traineeId)
|
||||||
|
: [...prev, traineeId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAvailableTrainees = availableData?.trainees?.filter(trainee =>
|
||||||
|
trainee.firstName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
trainee.lastName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
trainee.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
trainee.phone?.includes(searchTerm)
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
const isLoading = courseLoading || enrolledLoading;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner size="lg" className="mt-8" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500">Course not found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to manage this course
|
||||||
|
const canManage = isSuperAdmin || (isTrainer && course.trainerId === user.id);
|
||||||
|
if (!canManage) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500">You don't have permission to manage this course.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Course Enrollment</h1>
|
||||||
|
<p className="text-gray-600">Manage trainees for "{course.title}"</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<Link
|
||||||
|
to={`/courses/${id}`}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-4 w-4 mr-2" />
|
||||||
|
View Course
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssignModal(true)}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<UserPlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
Assign Trainees
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Info */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AcademicCapIcon className="h-8 w-8 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">{course.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Trainer: {course.trainer?.firstName} {course.trainer?.lastName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">Enrolled Trainees</p>
|
||||||
|
<p className="text-2xl font-bold text-primary-600">
|
||||||
|
{enrolledData?.trainees?.length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enrolled Trainees */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Enrolled Trainees</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{enrolledData?.trainees?.length || 0} trainees
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrolledData?.trainees?.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{enrolledData.trainees.map((trainee) => (
|
||||||
|
<div key={trainee.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-primary-600">
|
||||||
|
{trainee.firstName?.charAt(0)}{trainee.lastName?.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{trainee.firstName} {trainee.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{trainee.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">Progress</p>
|
||||||
|
<p className="text-sm font-medium">{trainee.progress || 0}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">Status</p>
|
||||||
|
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
trainee.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||||
|
trainee.status === 'completed' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
trainee.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{trainee.status === 'active' && <CheckCircleIcon className="h-3 w-3 mr-1" />}
|
||||||
|
{trainee.status === 'pending' && <ClockIcon className="h-3 w-3 mr-1" />}
|
||||||
|
{trainee.status === 'completed' && <CheckIcon className="h-3 w-3 mr-1" />}
|
||||||
|
{trainee.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<UsersIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">No trainees enrolled yet.</p>
|
||||||
|
<p className="text-sm text-gray-400">Assign trainees to get started.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign Trainees Modal */}
|
||||||
|
{showAssignModal && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Assign Trainees</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssignModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search trainees..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Trainees */}
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{availableLoading ? (
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
) : filteredAvailableTrainees.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredAvailableTrainees.map((trainee) => (
|
||||||
|
<div key={trainee.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTrainees.includes(trainee.id)}
|
||||||
|
onChange={() => toggleTraineeSelection(trainee.id)}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-primary-600">
|
||||||
|
{trainee.firstName?.charAt(0)}{trainee.lastName?.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{trainee.firstName} {trainee.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{trainee.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSingleAssign(trainee.id)}
|
||||||
|
disabled={assigning}
|
||||||
|
className="btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500">No available trainees found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssignModal(false)}
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={assigning}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkAssign}
|
||||||
|
disabled={selectedTrainees.length === 0 || assigning}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{assigning ? 'Assigning...' : `Assign ${selectedTrainees.length} Trainees`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseEnrollment;
|
||||||
|
|
@ -84,46 +84,13 @@ const Dashboard = () => {
|
||||||
const { data: userStats, isLoading: userStatsLoading } = useQuery(
|
const { data: userStats, isLoading: userStatsLoading } = useQuery(
|
||||||
['users', 'stats'],
|
['users', 'stats'],
|
||||||
() => usersAPI.getStats(),
|
() => usersAPI.getStats(),
|
||||||
{
|
{ enabled: isSuperAdmin }
|
||||||
enabled: isSuperAdmin,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log('User stats response:', data);
|
|
||||||
console.log('User stats data structure:', {
|
|
||||||
totalUsers: data?.stats?.totalUsers,
|
|
||||||
trainers: data?.stats?.trainers,
|
|
||||||
trainees: data?.stats?.trainees
|
|
||||||
});
|
|
||||||
console.log('Raw userStats object:', userStats);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('User stats error:', error);
|
|
||||||
console.error('User stats error response:', error.response);
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 0
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: courseStats, isLoading: courseStatsLoading } = useQuery(
|
const { data: courseStats, isLoading: courseStatsLoading } = useQuery(
|
||||||
['courses', 'stats'],
|
['courses', 'stats'],
|
||||||
() => coursesAPI.getStats(),
|
() => coursesAPI.getStats(),
|
||||||
{
|
{ enabled: isSuperAdmin || isTrainer }
|
||||||
enabled: isSuperAdmin || isTrainer,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log('Course stats response:', data);
|
|
||||||
console.log('Course stats data structure:', {
|
|
||||||
totalCourses: data?.stats?.totalCourses,
|
|
||||||
publishedCourses: data?.stats?.publishedCourses
|
|
||||||
});
|
|
||||||
console.log('Raw courseStats object:', courseStats);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Course stats error:', error);
|
|
||||||
console.error('Course stats error response:', error.response);
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 0
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// New queries for real counts
|
// New queries for real counts
|
||||||
|
|
@ -153,154 +120,100 @@ const Dashboard = () => {
|
||||||
return <LoadingSpinner size="lg" className="mt-8" />;
|
return <LoadingSpinner size="lg" className="mt-8" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug section to show raw API responses
|
const renderSuperAdminDashboard = () => (
|
||||||
const debugSection = (
|
<div className="space-y-6">
|
||||||
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<h3 className="text-lg font-medium text-yellow-800 mb-2">Debug Information</h3>
|
<div className="card">
|
||||||
<div className="text-sm text-yellow-700">
|
<div className="flex items-center">
|
||||||
<p><strong>User Stats:</strong> {JSON.stringify(userStats)}</p>
|
<div className="flex-shrink-0">
|
||||||
<p><strong>Course Stats:</strong> {JSON.stringify(courseStats)}</p>
|
<UsersIcon className="h-8 w-8 text-primary-600" />
|
||||||
<p><strong>User Role:</strong> {user?.role}</p>
|
</div>
|
||||||
<p><strong>Is Super Admin:</strong> {isSuperAdmin ? 'Yes' : 'No'}</p>
|
<div className="ml-4">
|
||||||
<p><strong>Card Values:</strong></p>
|
<p className="text-sm font-medium text-gray-500">Total Users</p>
|
||||||
<ul className="ml-4">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
<li>Total Users: {userStats?.data?.stats?.totalUsers} (type: {typeof userStats?.data?.stats?.totalUsers})</li>
|
{userStats?.data?.stats?.totalUsers || 0}
|
||||||
<li>Trainers: {userStats?.data?.stats?.trainers} (type: {typeof userStats?.data?.stats?.trainers})</li>
|
</p>
|
||||||
<li>Trainees: {userStats?.data?.stats?.trainees} (type: {typeof userStats?.data?.stats?.trainees})</li>
|
</div>
|
||||||
<li>Total Courses: {courseStats?.data?.stats?.totalCourses} (type: {typeof courseStats?.data?.stats?.totalCourses})</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
<p><strong>Direct Test Values:</strong></p>
|
|
||||||
<ul className="ml-4">
|
<div className="card">
|
||||||
<li>userStats?.data?.stats?.totalUsers: "{userStats?.data?.stats?.totalUsers}"</li>
|
<div className="flex items-center">
|
||||||
<li>userStats?.data?.stats?.trainers: "{userStats?.data?.stats?.trainers}"</li>
|
<div className="flex-shrink-0">
|
||||||
<li>userStats?.data?.stats?.trainees: "{userStats?.data?.stats?.trainees}"</li>
|
<AcademicCapIcon className="h-8 w-8 text-green-600" />
|
||||||
<li>courseStats?.data?.stats?.totalCourses: "{courseStats?.data?.stats?.totalCourses}"</li>
|
</div>
|
||||||
</ul>
|
<div className="ml-4">
|
||||||
<p><strong>Loading States:</strong></p>
|
<p className="text-sm font-medium text-gray-500">Trainers</p>
|
||||||
<ul className="ml-4">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
<li>userStatsLoading: {userStatsLoading ? 'true' : 'false'}</li>
|
{userStats?.data?.stats?.trainers || 0}
|
||||||
<li>courseStatsLoading: {courseStatsLoading ? 'true' : 'false'}</li>
|
</p>
|
||||||
</ul>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<UserGroupIcon className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Trainees</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{userStats?.data?.stats?.trainees || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<BookOpenIcon className="h-8 w-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Total Courses</p>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{courseStats?.data?.stats?.totalCourses || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSuperAdminDashboard = () => {
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
console.log('Rendering SuperAdmin dashboard with data:', {
|
<div className="card">
|
||||||
userStats,
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Recent Users</h3>
|
||||||
courseStats,
|
<div className="space-y-3">
|
||||||
totalUsers: userStats?.data?.stats?.totalUsers,
|
{/* Add recent users list here */}
|
||||||
trainers: userStats?.data?.stats?.trainers,
|
<p className="text-gray-500 text-sm">No recent users to display</p>
|
||||||
trainees: userStats?.data?.stats?.trainees,
|
|
||||||
totalCourses: courseStats?.data?.stats?.totalCourses
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Test Card to verify data */}
|
|
||||||
<div className="card bg-red-50 border-red-200">
|
|
||||||
<h3 className="text-lg font-medium text-red-800 mb-2">TEST CARD - Raw Data</h3>
|
|
||||||
<div className="text-sm text-red-700">
|
|
||||||
<p>userStats?.data?.stats?.totalUsers: {userStats?.data?.stats?.totalUsers}</p>
|
|
||||||
<p>userStats?.data?.stats?.trainers: {userStats?.data?.stats?.trainers}</p>
|
|
||||||
<p>userStats?.data?.stats?.trainees: {userStats?.data?.stats?.trainees}</p>
|
|
||||||
<p>courseStats?.data?.stats?.totalCourses: {courseStats?.data?.stats?.totalCourses}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SliderImageUpload />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="card">
|
||||||
<div className="card">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">System Overview</h3>
|
||||||
<div className="flex items-center">
|
<div className="space-y-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex justify-between items-center">
|
||||||
<UsersIcon className="h-8 w-8 text-primary-600" />
|
<span className="text-sm text-gray-600">Active Users</span>
|
||||||
</div>
|
<span className="text-sm font-medium">{userStats?.data?.stats?.activeUsers || 0}</span>
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Total Users</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{userStats?.data?.stats?.totalUsers || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Published Courses</span>
|
||||||
<div className="card">
|
<span className="text-sm font-medium">{courseStats?.data?.stats?.publishedCourses || 0}</span>
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<AcademicCapIcon className="h-8 w-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Trainers</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{userStats?.data?.stats?.trainers || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Total Enrollments</span>
|
||||||
<div className="card">
|
<span className="text-sm font-medium">{enrollmentStats?.data?.stats?.totalEnrollments || 0}</span>
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<UserGroupIcon className="h-8 w-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Trainees</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{userStats?.data?.stats?.trainees || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Featured Courses</span>
|
||||||
<div className="card">
|
<span className="text-sm font-medium">{courseStats?.data?.stats?.featuredCourses || 0}</span>
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<BookOpenIcon className="h-8 w-8 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Total Courses</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
|
||||||
{courseStats?.data?.stats?.totalCourses || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="card">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Recent Users</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Add recent users list here */}
|
|
||||||
<p className="text-gray-500 text-sm">No recent users to display</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SliderImageUpload />
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">System Overview</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Active Users</span>
|
|
||||||
<span className="text-sm font-medium">{userStats?.data?.stats?.activeUsers || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Published Courses</span>
|
|
||||||
<span className="text-sm font-medium">{courseStats?.data?.stats?.publishedCourses || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Total Enrollments</span>
|
|
||||||
<span className="text-sm font-medium">{enrollmentStats?.data?.stats?.totalEnrollments || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Featured Courses</span>
|
|
||||||
<span className="text-sm font-medium">{courseStats?.data?.stats?.featuredCourses || 0}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
};
|
);
|
||||||
|
|
||||||
const renderTrainerDashboard = () => (
|
const renderTrainerDashboard = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -484,9 +397,6 @@ const Dashboard = () => {
|
||||||
<p className="text-gray-600">Welcome back, {user?.firstName}! Here's what's happening.</p>
|
<p className="text-gray-600">Welcome back, {user?.firstName}! Here's what's happening.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debug section */}
|
|
||||||
{debugSection}
|
|
||||||
|
|
||||||
{isSuperAdmin && renderSuperAdminDashboard()}
|
{isSuperAdmin && renderSuperAdminDashboard()}
|
||||||
{isTrainer && renderTrainerDashboard()}
|
{isTrainer && renderTrainerDashboard()}
|
||||||
{isTrainee && renderTraineeDashboard()}
|
{isTrainee && renderTraineeDashboard()}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [identifier, setIdentifier] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -19,7 +19,7 @@ const Login = () => {
|
||||||
setError(''); // Clear previous errors
|
setError(''); // Clear previous errors
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await login(email, password);
|
const result = await login(identifier, password);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Add a small delay to ensure the user sees the success message
|
// Add a small delay to ensure the user sees the success message
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -66,19 +66,19 @@ const Login = () => {
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
<div className="rounded-md shadow-sm -space-y-px">
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="sr-only">
|
<label htmlFor="identifier" className="sr-only">
|
||||||
Email address
|
Email or Phone
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="identifier"
|
||||||
name="email"
|
name="identifier"
|
||||||
type="email"
|
type="text"
|
||||||
autoComplete="email"
|
autoComplete="identifier"
|
||||||
required
|
required
|
||||||
className="input-field rounded-t-lg"
|
className="input-field rounded-t-lg"
|
||||||
placeholder="Email address"
|
placeholder="Email or Phone"
|
||||||
value={email}
|
value={identifier}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -143,17 +143,6 @@ const Login = () => {
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Demo Accounts:
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 space-y-1 text-xs text-gray-500">
|
|
||||||
<p>Super Admin: admin@courseworx.com / admin123</p>
|
|
||||||
<p>Trainer: trainer@courseworx.com / trainer123</p>
|
|
||||||
<p>Trainee: trainee@courseworx.com / trainee123</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
283
frontend/src/pages/Setup.js
Normal file
283
frontend/src/pages/Setup.js
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const Setup = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
phone: ''
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const { updateUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters long');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.phone || formData.phone.trim() === '') {
|
||||||
|
setError('Phone number is required');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authAPI.setup({
|
||||||
|
firstName: formData.firstName,
|
||||||
|
lastName: formData.lastName,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
phone: formData.phone || null
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, user } = response.data;
|
||||||
|
|
||||||
|
console.log('Setup successful, user:', user);
|
||||||
|
console.log('About to update user in auth context...');
|
||||||
|
|
||||||
|
// Store token and set user
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
// Update the auth context user state directly
|
||||||
|
updateUser(user);
|
||||||
|
|
||||||
|
console.log('User updated in auth context, about to redirect...');
|
||||||
|
|
||||||
|
toast.success('System setup completed successfully!');
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Redirecting to dashboard...');
|
||||||
|
navigate('/dashboard');
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
const message = error.response?.data?.error || 'Setup failed';
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-primary-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
CourseWorx Setup
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Create your Super Admin account to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||||
|
Phone Number <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 border border-red-200">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">
|
||||||
|
Setup Error
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
) : (
|
||||||
|
'Complete Setup'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Setup;
|
||||||
164
frontend/src/pages/TraineeLogin.js
Normal file
164
frontend/src/pages/TraineeLogin.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
import {
|
||||||
|
AcademicCapIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeSlashIcon,
|
||||||
|
UserIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const TraineeLogin = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
identifier: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.identifier || !formData.password) {
|
||||||
|
toast.error('Please fill in all fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await authAPI.traineeLogin(formData);
|
||||||
|
|
||||||
|
if (response.token) {
|
||||||
|
await login(response.token, response.user);
|
||||||
|
toast.success('Welcome back!');
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.error || 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary-500 flex items-center justify-center">
|
||||||
|
<AcademicCapIcon className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Trainee Login
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Access your enrolled courses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="identifier" className="block text-sm font-medium text-gray-700">
|
||||||
|
Email or Phone Number
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<UserIcon className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="identifier"
|
||||||
|
name="identifier"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.identifier}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||||
|
placeholder="Enter your email or phone number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<LockClosedIcon className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="appearance-none block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeSlashIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">
|
||||||
|
Need help?
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Contact your trainer or administrator for access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TraineeLogin;
|
||||||
|
|
@ -436,8 +436,8 @@ const Users = () => {
|
||||||
<input type="email" name="email" value={userForm.email} onChange={handleFormChange} className="input-field w-full" required />
|
<input type="email" name="email" value={userForm.email} onChange={handleFormChange} className="input-field w-full" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Phone</label>
|
<label className="block text-sm font-medium mb-1">Phone <span className="text-red-500">*</span></label>
|
||||||
<input type="text" name="phone" value={userForm.phone} onChange={handleFormChange} className="input-field w-full" />
|
<input type="tel" name="phone" value={userForm.phone} onChange={handleFormChange} className="input-field w-full" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Role</label>
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import axios from 'axios';
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: 'http://localhost:5000/api',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
|
@ -36,13 +36,17 @@ api.interceptors.response.use(
|
||||||
|
|
||||||
// Auth API
|
// Auth API
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
login: (email, password) => api.post('/auth/login', { email, password }),
|
login: (identifier, password) => api.post('/auth/login', { identifier, password }),
|
||||||
getCurrentUser: () => api.get('/auth/me'),
|
traineeLogin: (credentials) => api.post('/auth/trainee-login', credentials),
|
||||||
updateProfile: (data) => api.put('/auth/profile', data),
|
checkEnrollment: () => api.post('/auth/check-enrollment'),
|
||||||
changePassword: (currentPassword, newPassword) =>
|
|
||||||
api.put('/auth/change-password', { currentPassword, newPassword }),
|
|
||||||
firstPasswordChange: (data) => api.put('/auth/first-password-change', data),
|
|
||||||
register: (userData) => api.post('/auth/register', userData),
|
register: (userData) => api.post('/auth/register', userData),
|
||||||
|
getCurrentUser: () => api.get('/auth/me'),
|
||||||
|
changePassword: (data) => api.put('/auth/change-password', data),
|
||||||
|
forgotPassword: (email) => api.post('/auth/forgot-password', { email }),
|
||||||
|
resetPassword: (token, password) => api.post('/auth/reset-password', { token, password }),
|
||||||
|
verifyToken: () => api.get('/auth/verify'),
|
||||||
|
setupStatus: () => api.get('/auth/setup-status'),
|
||||||
|
setup: (userData) => api.post('/auth/setup', userData)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Users API
|
// Users API
|
||||||
|
|
@ -76,7 +80,7 @@ export const coursesAPI = {
|
||||||
delete: (id) => api.delete(`/courses/${id}`),
|
delete: (id) => api.delete(`/courses/${id}`),
|
||||||
publish: (id, isPublished) => api.put(`/courses/${id}/publish`, { isPublished }),
|
publish: (id, isPublished) => api.put(`/courses/${id}/publish`, { isPublished }),
|
||||||
assignTrainer: (id, trainerId) => api.put(`/courses/${id}/assign-trainer`, { trainerId }),
|
assignTrainer: (id, trainerId) => api.put(`/courses/${id}/assign-trainer`, { trainerId }),
|
||||||
getAvailableTrainers: () => api.get('/courses/trainers/available'),
|
getAvailableTrainers: () => api.get('/courses/trainers/available').then(res => res.data),
|
||||||
getCategories: () => api.get('/courses/categories/all'),
|
getCategories: () => api.get('/courses/categories/all'),
|
||||||
getStats: () => api.get('/courses/stats/overview'),
|
getStats: () => api.get('/courses/stats/overview'),
|
||||||
uploadCourseImage: (courseName, file) => {
|
uploadCourseImage: (courseName, file) => {
|
||||||
|
|
@ -111,15 +115,19 @@ export const courseContentAPI = {
|
||||||
|
|
||||||
// Enrollments API
|
// Enrollments API
|
||||||
export const enrollmentsAPI = {
|
export const enrollmentsAPI = {
|
||||||
getAll: (params) => api.get('/enrollments', { params }),
|
getAll: (params) => api.get('/enrollments', { params }).then(res => res.data),
|
||||||
|
getMy: (params) => api.get('/enrollments/my', { params }).then(res => res.data),
|
||||||
|
getById: (id) => api.get(`/enrollments/${id}`).then(res => res.data),
|
||||||
create: (data) => api.post('/enrollments', data),
|
create: (data) => api.post('/enrollments', data),
|
||||||
getMy: (params) => api.get('/enrollments/my', { params }),
|
update: (id, data) => api.put(`/enrollments/${id}`, data),
|
||||||
getById: (id) => api.get(`/enrollments/${id}`),
|
delete: (id) => api.delete(`/enrollments/${id}`),
|
||||||
updateStatus: (id, data) => api.put(`/enrollments/${id}/status`, data),
|
updateStatus: (id, status, notes) => api.put(`/enrollments/${id}/status`, { status, notes }),
|
||||||
updatePayment: (id, data) => api.put(`/enrollments/${id}/payment`, data),
|
|
||||||
updateProgress: (id, progress) => api.put(`/enrollments/${id}/progress`, { progress }),
|
|
||||||
cancel: (id) => api.delete(`/enrollments/${id}`),
|
|
||||||
getStats: () => api.get('/enrollments/stats/overview'),
|
getStats: () => api.get('/enrollments/stats/overview'),
|
||||||
|
// New enrollment management endpoints
|
||||||
|
bulkEnroll: (data) => api.post('/enrollments/bulk', data),
|
||||||
|
assignTrainee: (data) => api.post('/enrollments/assign', data),
|
||||||
|
getCourseTrainees: (courseId, params) => api.get(`/enrollments/course/${courseId}/trainees`, { params }).then(res => res.data),
|
||||||
|
getAvailableTrainees: (params) => api.get('/enrollments/available-trainees', { params }).then(res => res.data)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Attendance API
|
// Attendance API
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,37 @@ if not exist "frontend\node_modules" (
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
REM Check for port conflicts
|
||||||
|
echo 🔍 Checking for port conflicts...
|
||||||
|
|
||||||
|
netstat -an | findstr ":5000" >nul 2>&1
|
||||||
|
if %errorlevel% equ 0 (
|
||||||
|
echo ⚠️ Port 5000 is already in use
|
||||||
|
echo This might be another CourseWorx instance or different application
|
||||||
|
set /p choice="Do you want to continue anyway? (y/N): "
|
||||||
|
if /i not "%choice%"=="y" (
|
||||||
|
echo Stopping startup process...
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
netstat -an | findstr ":3000" >nul 2>&1
|
||||||
|
if %errorlevel% equ 0 (
|
||||||
|
echo ⚠️ Port 3000 is already in use
|
||||||
|
echo This might be another CourseWorx instance or different application
|
||||||
|
set /p choice="Do you want to continue anyway? (y/N): "
|
||||||
|
if /i not "%choice%"=="y" (
|
||||||
|
echo Stopping startup process...
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ✅ Port check completed
|
||||||
|
echo.
|
||||||
|
|
||||||
echo 🚀 Starting CourseWorx...
|
echo 🚀 Starting CourseWorx...
|
||||||
echo.
|
echo.
|
||||||
echo 📱 Frontend will be available at: http://localhost:3000
|
echo 📱 Frontend will be available at: http://localhost:3000
|
||||||
|
|
@ -56,5 +87,11 @@ echo.
|
||||||
|
|
||||||
REM Start both frontend and backend
|
REM Start both frontend and backend
|
||||||
npm run start
|
npm run start
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo ❌ Error starting CourseWorx
|
||||||
|
echo Please check the error messages above and try again
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
pause
|
pause
|
||||||
|
|
@ -1,61 +1,47 @@
|
||||||
# CourseWorx Start Script
|
# CourseWorx Start Script
|
||||||
Write-Host ""
|
Write-Host "Starting CourseWorx..."
|
||||||
Write-Host "========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host " CourseWorx - Starting Application" -ForegroundColor Cyan
|
|
||||||
Write-Host "========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Check if Node.js is installed
|
# Check if Node.js is installed
|
||||||
try {
|
$nodeVersion = node --version
|
||||||
$nodeVersion = node --version
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Host "✅ Node.js version: $nodeVersion" -ForegroundColor Green
|
Write-Host "ERROR: Node.js is not installed or not in PATH" -ForegroundColor Red
|
||||||
} catch {
|
|
||||||
Write-Host "❌ ERROR: Node.js is not installed or not in PATH" -ForegroundColor Red
|
|
||||||
Write-Host "Please install Node.js from https://nodejs.org/" -ForegroundColor Yellow
|
|
||||||
Read-Host "Press Enter to exit"
|
Read-Host "Press Enter to exit"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
Write-Host "Node.js version: $nodeVersion" -ForegroundColor Green
|
||||||
|
|
||||||
# Check if npm is installed
|
# Check if npm is installed
|
||||||
try {
|
$npmVersion = npm --version
|
||||||
$npmVersion = npm --version
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Host "✅ npm version: $npmVersion" -ForegroundColor Green
|
Write-Host "ERROR: npm is not installed or not in PATH" -ForegroundColor Red
|
||||||
} catch {
|
|
||||||
Write-Host "❌ ERROR: npm is not installed or not in PATH" -ForegroundColor Red
|
|
||||||
Read-Host "Press Enter to exit"
|
Read-Host "Press Enter to exit"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
Write-Host "npm version: $npmVersion" -ForegroundColor Green
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Check if dependencies are installed
|
# Check if dependencies are installed
|
||||||
if (-not (Test-Path "node_modules")) {
|
if (-not (Test-Path "node_modules")) {
|
||||||
Write-Host "📦 Installing root dependencies..." -ForegroundColor Yellow
|
Write-Host "Installing root dependencies..." -ForegroundColor Yellow
|
||||||
npm install
|
npm install
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path "backend\node_modules")) {
|
if (-not (Test-Path "backend\node_modules")) {
|
||||||
Write-Host "📦 Installing backend dependencies..." -ForegroundColor Yellow
|
Write-Host "Installing backend dependencies..." -ForegroundColor Yellow
|
||||||
Set-Location backend
|
Set-Location backend
|
||||||
npm install
|
npm install
|
||||||
Set-Location ..
|
Set-Location ..
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path "frontend\node_modules")) {
|
if (-not (Test-Path "frontend\node_modules")) {
|
||||||
Write-Host "📦 Installing frontend dependencies..." -ForegroundColor Yellow
|
Write-Host "Installing frontend dependencies..." -ForegroundColor Yellow
|
||||||
Set-Location frontend
|
Set-Location frontend
|
||||||
npm install
|
npm install
|
||||||
Set-Location ..
|
Set-Location ..
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host "Starting CourseWorx..." -ForegroundColor Green
|
||||||
Write-Host "🚀 Starting CourseWorx..." -ForegroundColor Green
|
Write-Host "Frontend: http://localhost:3000" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host "Backend: http://localhost:5000" -ForegroundColor Cyan
|
||||||
Write-Host "📱 Frontend will be available at: http://localhost:3000" -ForegroundColor Cyan
|
|
||||||
Write-Host "🔧 Backend API will be available at: http://localhost:5000" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "💡 To stop the application, press Ctrl+C" -ForegroundColor Yellow
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Start both frontend and backend
|
# Start both frontend and backend
|
||||||
npm run start
|
npm run start
|
||||||
10
tat -an findstr 5000
Normal file
10
tat -an findstr 5000
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[33md7075fd[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mmain[m[33m)[m Fix dashboard data access issue - Fixed data access path to use .data wrapper - Updated all dashboard cards to use correct data structure - Updated debug section to show correct data access - Fixed trainer and trainee dashboard data access - The issue was that API response has extra 'data' wrapper
|
||||||
|
[33ma4aba8e[m Add comprehensive debugging for dashboard data display - Added detailed console logging for data access - Enhanced debug section with direct value testing - Added test card to verify raw data values - Added loading state debugging - Disabled React Query caching to force fresh data
|
||||||
|
[33md0ee0c1[m Add debugging to dashboard cards and fix data display - Added console logging to see exact data being passed to dashboard cards - Enhanced debug section to show data types and values - Added specific debugging for SuperAdmin dashboard rendering
|
||||||
|
[33mceb1f56[m Fix header layout and add debugging for dashboard statistics - Removed 'CourseWorx' text from header - Fixed logo path to use /images/cx-logo.png - Moved navigation items next to Home icon - Added debugging to API calls and dashboard - Added console logging to backend stats endpoints
|
||||||
|
[33mcd5370f[m[33m ([m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m)[m v1.1.4: Modern header navigation & real-time dashboard statistics - Removed sidebar completely and moved navigation to header - Added CourseWorx logo and Home icon for dashboard access - Moved Courses and Users links to header with icons - Updated all dashboard cards to show real database counts - Enhanced backend API endpoints for role-specific statistics - Fixed ESLint warnings in CourseContent.js and CourseContentViewer.js - Improved responsive design and user experience
|
||||||
|
[33m6cad9b6[m v1.1.3: Enhanced login experience, trainer profiles, modern course details, and dashboard improvements
|
||||||
|
[33mdece9c8[m v1.1.2: Add trainer assignment system and navigation improvements
|
||||||
|
[33m15281c5[m[33m ([m[1;33mtag: [m[1;33mv1.1.1[m[33m)[m Version 1.1.1 - Enhanced Course Content Management Interface
|
||||||
|
[33mb4d90c6[m[33m ([m[1;33mtag: [m[1;33mv1.1.0[m[33m)[m Release v1.1.0 - Course Content & Enrollment Management
|
||||||
|
[33mcca9032[m[33m ([m[1;33mtag: [m[1;33mv1.0.0[m[33m)[m Release v1.0.0 - Complete Course Management System
|
||||||
680
version.txt
680
version.txt
|
|
@ -1,635 +1,57 @@
|
||||||
CourseWorx v1.1.4 - Modern Header Navigation & Real-Time Dashboard
|
CourseWorx v1.2.0
|
||||||
==========================================================
|
|
||||||
|
|
||||||
This version modernizes the navigation system by removing the sidebar and implementing a clean header-based navigation with real-time dashboard statistics.
|
CHANGELOG:
|
||||||
|
|
||||||
MAJOR FEATURES IMPLEMENTED:
|
v1.2.0 (2025-08-20)
|
||||||
===========================
|
===================
|
||||||
|
|
||||||
1. MODERN HEADER NAVIGATION
|
FEATURES & IMPROVEMENTS:
|
||||||
----------------------------
|
- ✨ Implemented responsive dropdown menu for course action buttons
|
||||||
- Completely removed sidebar (both mobile and desktop versions)
|
- 🔧 Fixed trainer assignment dropdown population issue
|
||||||
- Moved all navigation to a clean, sticky header
|
- 🛠️ Resolved available trainees API routing conflict
|
||||||
- Added CourseWorx logo prominently in the header
|
- 📱 Enhanced mobile responsiveness across the application
|
||||||
- Implemented Home icon next to logo for dashboard navigation
|
- ♿ Improved accessibility with ARIA labels and keyboard navigation
|
||||||
- Moved Courses and Users links to header with icons
|
|
||||||
- Removed Dashboard link from navigation (accessible via logo/Home icon)
|
|
||||||
- Responsive design with icons-only on mobile, icons+text on desktop
|
|
||||||
- Future-ready design for icon-only navigation
|
|
||||||
|
|
||||||
2. REAL-TIME DASHBOARD STATISTICS
|
BUG FIXES:
|
||||||
----------------------------------
|
- 🐛 Fixed trainer assignment dropdown showing "No available trainers"
|
||||||
- Updated all dashboard cards to show real database counts
|
- 🐛 Resolved 500 Internal Server Error in available-trainees endpoint
|
||||||
- Enhanced backend API endpoints for role-specific statistics
|
- 🐛 Fixed route ordering issue where /:id was catching /available-trainees
|
||||||
- Super Admin dashboard shows total users, trainers, trainees, courses
|
- 🐛 Corrected API response structure for getAvailableTrainers function
|
||||||
- Trainer dashboard shows my courses, published courses, my students
|
- 🐛 Fixed setup page redirect not working after Super Admin creation
|
||||||
- Trainee dashboard shows enrolled courses, attendance rate, completed courses
|
- 🐛 Resolved ESLint warnings in AuthContext and Setup components
|
||||||
- Added new API queries for enrollment and course statistics
|
|
||||||
- All cards now display actual data instead of hardcoded values
|
|
||||||
|
|
||||||
3. BACKEND API ENHANCEMENTS
|
|
||||||
----------------------------
|
|
||||||
- Enhanced `/api/enrollments/stats/overview` endpoint:
|
|
||||||
- Added `myStudents` count for trainers (unique students)
|
|
||||||
- Added `myEnrollments` count for trainees
|
|
||||||
- Added `completedCourses` count for trainees
|
|
||||||
- Enhanced `/api/courses/stats/overview` endpoint:
|
|
||||||
- Added `myCourses` count for trainers
|
|
||||||
- Added `myPublishedCourses` count for trainers
|
|
||||||
- Role-based data filtering and access control
|
|
||||||
- Improved statistics accuracy and performance
|
|
||||||
|
|
||||||
4. FRONTEND IMPROVEMENTS
|
|
||||||
-------------------------
|
|
||||||
- Clean, modern header layout with proper spacing
|
|
||||||
- User dropdown menu with profile and logout options
|
|
||||||
- Responsive navigation that adapts to screen size
|
|
||||||
- Enhanced visual hierarchy and user experience
|
|
||||||
- Improved accessibility with proper ARIA attributes
|
|
||||||
- Better mobile experience with touch-friendly navigation
|
|
||||||
|
|
||||||
5. CODE QUALITY & MAINTENANCE
|
|
||||||
-----------------------------
|
|
||||||
- Fixed ESLint warnings in CourseContent.js and CourseContentViewer.js
|
|
||||||
- Removed unused imports and variables
|
|
||||||
- Cleaned up duplicate imports (PlusIcon)
|
|
||||||
- Improved code organization and structure
|
|
||||||
- Enhanced maintainability and readability
|
|
||||||
|
|
||||||
TECHNICAL IMPROVEMENTS:
|
TECHNICAL IMPROVEMENTS:
|
||||||
=======================
|
- 🔄 Reordered Express.js routes to prevent conflicts
|
||||||
|
- 📊 Added comprehensive logging for debugging trainer assignment
|
||||||
1. LAYOUT SYSTEM
|
- 🎯 Improved API response handling in frontend services
|
||||||
-----------------
|
- 🚀 Enhanced user experience with smooth dropdown animations
|
||||||
- Removed sidebar-based layout completely
|
- 🎨 Implemented consistent hover effects and transitions
|
||||||
- Implemented header-centric navigation
|
|
||||||
- Responsive design with mobile-first approach
|
RESPONSIVE DESIGN:
|
||||||
- Clean separation of navigation and content areas
|
- 📱 Converted horizontal action buttons to compact 3-dots dropdown
|
||||||
- Improved content area utilization
|
- 🎨 Added professional dropdown styling with shadows and rings
|
||||||
|
- 🔄 Implemented click-outside functionality for dropdown menus
|
||||||
2. API INTEGRATION
|
- ⌨️ Added keyboard navigation support (Enter/Space keys)
|
||||||
-------------------
|
- 🎯 Optimized positioning for all screen sizes
|
||||||
- Enhanced statistics endpoints with role-specific data
|
|
||||||
- Improved data fetching efficiency
|
CODE QUALITY:
|
||||||
- Better error handling and loading states
|
- 🧹 Fixed React Hook dependency warnings
|
||||||
- Real-time data updates with React Query
|
- 🚫 Removed unused variables and imports
|
||||||
- Optimized API calls for dashboard performance
|
- 📝 Added comprehensive code documentation
|
||||||
|
- 🎯 Improved error handling and user feedback
|
||||||
3. USER EXPERIENCE
|
- 🔍 Enhanced debugging capabilities
|
||||||
-------------------
|
|
||||||
- Streamlined navigation with fewer clicks
|
PREVIOUS VERSIONS:
|
||||||
- Better visual feedback and hover effects
|
==================
|
||||||
- Improved accessibility and keyboard navigation
|
|
||||||
- Cleaner, more professional appearance
|
v1.1.0 (2025-08-20)
|
||||||
- Faster access to key features
|
- Initial CourseWorx application setup
|
||||||
|
- User authentication and role management
|
||||||
4. PERFORMANCE OPTIMIZATIONS
|
- Course management system
|
||||||
-----------------------------
|
- Basic enrollment functionality
|
||||||
- Reduced layout complexity by removing sidebar
|
- File upload capabilities
|
||||||
- Optimized API calls for dashboard statistics
|
|
||||||
- Improved rendering performance
|
v1.0.0 (2025-08-20)
|
||||||
- Better caching strategies for statistics data
|
- Project initialization
|
||||||
- Enhanced mobile performance
|
- Basic project structure
|
||||||
|
- Development environment setup
|
||||||
BUG FIXES & RESOLUTIONS:
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. ESLINT WARNINGS
|
|
||||||
-------------------
|
|
||||||
- Fixed unused `useAuth` import in CourseContentViewer.js
|
|
||||||
- Fixed unused `queryClient` variable in CourseContentViewer.js
|
|
||||||
- Removed duplicate `PlusIcon` import in CourseContent.js
|
|
||||||
- Updated icon references for consistency
|
|
||||||
- Cleaned up unused variables and imports
|
|
||||||
|
|
||||||
2. NAVIGATION ISSUES
|
|
||||||
---------------------
|
|
||||||
- Resolved sidebar navigation complexity
|
|
||||||
- Fixed mobile navigation accessibility
|
|
||||||
- Improved navigation state management
|
|
||||||
- Enhanced user menu dropdown functionality
|
|
||||||
- Better responsive behavior
|
|
||||||
|
|
||||||
3. DASHBOARD ACCURACY
|
|
||||||
----------------------
|
|
||||||
- Fixed hardcoded values in dashboard cards
|
|
||||||
- Implemented real database counts for all statistics
|
|
||||||
- Enhanced role-based data filtering
|
|
||||||
- Improved data accuracy and reliability
|
|
||||||
- Better error handling for statistics
|
|
||||||
|
|
||||||
DEPENDENCIES & TECHNOLOGIES:
|
|
||||||
============================
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- React 18.x
|
|
||||||
- React Router v6
|
|
||||||
- React Query (TanStack Query)
|
|
||||||
- Tailwind CSS
|
|
||||||
- Heroicons
|
|
||||||
- Axios
|
|
||||||
- React Hot Toast
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Node.js
|
|
||||||
- Express.js
|
|
||||||
- Sequelize ORM
|
|
||||||
- PostgreSQL
|
|
||||||
- JWT (jsonwebtoken)
|
|
||||||
- bcryptjs
|
|
||||||
- multer
|
|
||||||
- express-validator
|
|
||||||
|
|
||||||
FILE STRUCTURE CHANGES:
|
|
||||||
======================
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- Updated Layout.js with header-based navigation
|
|
||||||
- Enhanced Dashboard.js with real-time statistics
|
|
||||||
- Fixed ESLint issues in CourseContent.js and CourseContentViewer.js
|
|
||||||
- Improved component organization and structure
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Enhanced enrollments.js with role-specific statistics
|
|
||||||
- Updated courses.js with trainer-specific data
|
|
||||||
- Improved API response structure and accuracy
|
|
||||||
|
|
||||||
CONFIGURATION UPDATES:
|
|
||||||
======================
|
|
||||||
|
|
||||||
Navigation:
|
|
||||||
- Removed sidebar configuration
|
|
||||||
- Updated header navigation structure
|
|
||||||
- Enhanced responsive breakpoints
|
|
||||||
- Improved mobile navigation
|
|
||||||
|
|
||||||
API Endpoints:
|
|
||||||
- Enhanced statistics endpoints with role-based filtering
|
|
||||||
- Improved data accuracy and performance
|
|
||||||
- Better error handling and validation
|
|
||||||
|
|
||||||
SECURITY CONSIDERATIONS:
|
|
||||||
========================
|
|
||||||
|
|
||||||
- Maintained role-based access control
|
|
||||||
- Enhanced API endpoint security
|
|
||||||
- Improved data filtering and validation
|
|
||||||
- Better user session management
|
|
||||||
- Enhanced authentication flow
|
|
||||||
|
|
||||||
DEPLOYMENT READINESS:
|
|
||||||
=====================
|
|
||||||
|
|
||||||
- Updated navigation system for production
|
|
||||||
- Enhanced dashboard performance
|
|
||||||
- Improved mobile responsiveness
|
|
||||||
- Better user experience across devices
|
|
||||||
- Optimized API performance
|
|
||||||
|
|
||||||
This version (1.1.4) modernizes the CourseWorx platform with a clean, header-based navigation system and real-time dashboard statistics, providing a more professional and user-friendly experience while maintaining all existing functionality.
|
|
||||||
|
|
||||||
Release Date: [Current Date]
|
|
||||||
Version: 1.1.4
|
|
||||||
Status: Production Ready
|
|
||||||
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
CourseWorx v1.1.3 - Enhanced Login Experience & Dashboard Improvements
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
This version adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System.
|
|
||||||
|
|
||||||
MAJOR FEATURES IMPLEMENTED:
|
|
||||||
===========================
|
|
||||||
|
|
||||||
1. AUTHENTICATION & AUTHORIZATION
|
|
||||||
--------------------------------
|
|
||||||
- JWT-based authentication system
|
|
||||||
- Role-based access control (Super Admin, Trainer, Trainee)
|
|
||||||
- Protected routes with role-based permissions
|
|
||||||
- User session management with localStorage
|
|
||||||
- Password hashing with bcryptjs
|
|
||||||
- Login/logout functionality with proper error handling
|
|
||||||
|
|
||||||
2. USER MANAGEMENT SYSTEM
|
|
||||||
-------------------------
|
|
||||||
- Complete CRUD operations for users
|
|
||||||
- User roles: Super Admin, Trainer, Trainee
|
|
||||||
- User status management (Active/Inactive)
|
|
||||||
- User search and filtering capabilities
|
|
||||||
- Pagination for user lists
|
|
||||||
- CSV import functionality for bulk user creation
|
|
||||||
- Super Admin password change functionality for any user
|
|
||||||
- User profile management
|
|
||||||
|
|
||||||
3. COURSE MANAGEMENT SYSTEM
|
|
||||||
---------------------------
|
|
||||||
- Complete CRUD operations for courses
|
|
||||||
- Course publishing/unpublishing functionality
|
|
||||||
- Course categories and metadata
|
|
||||||
- Course image upload functionality
|
|
||||||
- Course search and filtering
|
|
||||||
- Course statistics and analytics
|
|
||||||
|
|
||||||
4. COURSE CONTENT MANAGEMENT SYSTEM
|
|
||||||
-----------------------------------
|
|
||||||
- Multi-type content support (Documents, Images, Videos, Articles, Quizzes, Certificates)
|
|
||||||
- File upload system with 100MB limit and type validation
|
|
||||||
- Content ordering and publishing control
|
|
||||||
- Quiz system with multiple question types (multiple choice, single choice, true/false, text, file upload)
|
|
||||||
- Points system for gamification
|
|
||||||
- Required/optional content marking
|
|
||||||
- Metadata storage for additional information
|
|
||||||
- File management with automatic cleanup
|
|
||||||
|
|
||||||
5. ENROLLMENT & SUBSCRIBER MANAGEMENT
|
|
||||||
--------------------------------------
|
|
||||||
- Complete enrollment lifecycle (enroll, unenroll, track progress)
|
|
||||||
- Status tracking (pending, active, completed, cancelled)
|
|
||||||
- Payment management (pending, paid, failed, refunded)
|
|
||||||
- Progress tracking (0-100%) with automatic updates
|
|
||||||
- Course capacity limits and validation
|
|
||||||
- Certificate issuance tracking
|
|
||||||
- Enrollment analytics and statistics
|
|
||||||
- Role-based enrollment access control
|
|
||||||
|
|
||||||
6. FRONTEND USER INTERFACE
|
|
||||||
---------------------------
|
|
||||||
- Modern, responsive UI using Tailwind CSS
|
|
||||||
- Heroicons for consistent iconography
|
|
||||||
- Modal system for user add/edit operations
|
|
||||||
- Loading states and error handling
|
|
||||||
- Toast notifications for user feedback
|
|
||||||
- Pagination components
|
|
||||||
- Search and filter interfaces
|
|
||||||
- Role-based UI elements
|
|
||||||
|
|
||||||
7. INTERNATIONALIZATION (i18n)
|
|
||||||
-------------------------------
|
|
||||||
- English (LTR) and Arabic (RTL) language support
|
|
||||||
- Dynamic language switching
|
|
||||||
- Document direction switching (LTR/RTL)
|
|
||||||
- Translation system using react-i18next
|
|
||||||
- Localized text for all user-facing content
|
|
||||||
|
|
||||||
8. FILE UPLOAD SYSTEM
|
|
||||||
----------------------
|
|
||||||
- Image upload for course thumbnails
|
|
||||||
- Slider image upload for homepage
|
|
||||||
- Multi-type content file uploads (documents, images, videos)
|
|
||||||
- Multer middleware for file handling
|
|
||||||
- File validation and size limits (100MB max)
|
|
||||||
- Organized file storage structure
|
|
||||||
- Automatic directory creation
|
|
||||||
- File type validation for different content types
|
|
||||||
|
|
||||||
9. BACKEND API SYSTEM
|
|
||||||
----------------------
|
|
||||||
- RESTful API design
|
|
||||||
- Express.js server with middleware
|
|
||||||
- Sequelize ORM with PostgreSQL
|
|
||||||
- Input validation using express-validator
|
|
||||||
- Error handling and logging
|
|
||||||
- CORS configuration
|
|
||||||
- Environment-based configuration
|
|
||||||
- Course content management APIs
|
|
||||||
- Enrollment management APIs
|
|
||||||
|
|
||||||
10. DATABASE SYSTEM
|
|
||||||
-------------------
|
|
||||||
- PostgreSQL database with Sequelize ORM
|
|
||||||
- User model with proper relationships
|
|
||||||
- Course model with metadata
|
|
||||||
- CourseContent model with multi-type support
|
|
||||||
- QuizQuestion model for quiz functionality
|
|
||||||
- Enhanced Enrollment model with comprehensive tracking
|
|
||||||
- Attendance tracking
|
|
||||||
- Assignment management
|
|
||||||
- Database migrations and seeding
|
|
||||||
|
|
||||||
NEW FEATURES IN v1.1.3:
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. ENHANCED LOGIN EXPERIENCE
|
|
||||||
-----------------------------
|
|
||||||
- Fixed page reload issue that was hiding error messages
|
|
||||||
- Improved error message visibility with better styling
|
|
||||||
- Added delays to prevent immediate redirects after login
|
|
||||||
- Enhanced error handling with clear visual feedback
|
|
||||||
- Better user experience with proper error persistence
|
|
||||||
- Added error icons and improved error message styling
|
|
||||||
|
|
||||||
2. TRAINER PROFILE SYSTEM
|
|
||||||
--------------------------
|
|
||||||
- New trainer profile page with comprehensive information
|
|
||||||
- Clickable instructor sections in course details
|
|
||||||
- Trainer qualifications and experience display
|
|
||||||
- Course listings by trainer
|
|
||||||
- Professional trainer profile layout
|
|
||||||
- Direct navigation from course pages to trainer profiles
|
|
||||||
|
|
||||||
3. MODERN COURSE DETAIL PAGE
|
|
||||||
-----------------------------
|
|
||||||
- Redesigned course detail page with Udemy-like layout
|
|
||||||
- Left column with course information and content
|
|
||||||
- Right sidebar with pricing and enrollment options
|
|
||||||
- Professional course preview with play button
|
|
||||||
- Enhanced "What you'll learn" section with checkmarks
|
|
||||||
- Course content structure with expandable sections
|
|
||||||
- Requirements and description sections
|
|
||||||
- Course includes section with feature icons
|
|
||||||
|
|
||||||
4. DASHBOARD IMPROVEMENTS
|
|
||||||
--------------------------
|
|
||||||
- Made all dashboard cards clickable with proper navigation
|
|
||||||
- Added hover effects and arrow icons for better UX
|
|
||||||
- Fixed hardcoded values and improved data display
|
|
||||||
- Enhanced Quick Actions with proper links
|
|
||||||
- Course and enrollment items are now clickable
|
|
||||||
- Better visual feedback with transitions and hover effects
|
|
||||||
|
|
||||||
5. ESLINT & CODE QUALITY
|
|
||||||
-------------------------
|
|
||||||
- Fixed all ESLint warnings across components
|
|
||||||
- Removed unused imports and variables
|
|
||||||
- Improved code organization and structure
|
|
||||||
- Enhanced code maintainability
|
|
||||||
- Cleaned up debugging code and console logs
|
|
||||||
|
|
||||||
NEW FEATURES IN v1.1.2:
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. TRAINER ASSIGNMENT SYSTEM
|
|
||||||
-----------------------------
|
|
||||||
- Super Admin can assign trainers to courses
|
|
||||||
- Trainer assignment modal with dropdown selection
|
|
||||||
- Available trainers API endpoint with proper authentication
|
|
||||||
- Real-time trainer assignment with immediate UI updates
|
|
||||||
- Role-based access control (Super Admin only)
|
|
||||||
- Comprehensive error handling and validation
|
|
||||||
- Debug information for troubleshooting
|
|
||||||
|
|
||||||
2. NAVIGATION REORGANIZATION
|
|
||||||
-----------------------------
|
|
||||||
- Moved logout and profile links to top-right user dropdown
|
|
||||||
- Cleaned up sidebar navigation (Dashboard, Courses, Users only)
|
|
||||||
- Modern user dropdown menu with avatar and role display
|
|
||||||
- Click-outside-to-close functionality for dropdown
|
|
||||||
- Responsive design for mobile and desktop
|
|
||||||
- Improved user experience with better navigation hierarchy
|
|
||||||
|
|
||||||
3. ENHANCED USER INTERFACE
|
|
||||||
---------------------------
|
|
||||||
- User avatar with initials in top-right corner
|
|
||||||
- Dropdown menu with Profile and Logout options
|
|
||||||
- Clean sidebar with only essential navigation items
|
|
||||||
- Better visual hierarchy and spacing
|
|
||||||
- Improved accessibility with proper ARIA attributes
|
|
||||||
- Mobile-responsive dropdown menu
|
|
||||||
|
|
||||||
4. DEBUGGING & TROUBLESHOOTING
|
|
||||||
-------------------------------
|
|
||||||
- Added comprehensive debug information for trainer assignment
|
|
||||||
- Backend logging for trainer API requests
|
|
||||||
- Frontend error handling and user feedback
|
|
||||||
- Authentication and authorization debugging
|
|
||||||
- API call monitoring and error tracking
|
|
||||||
|
|
||||||
NEW FEATURES IN v1.1.1:
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. DEDICATED COURSE CONTENT MANAGEMENT PAGE
|
|
||||||
-------------------------------------------
|
|
||||||
- Separated content management from course editing
|
|
||||||
- Dedicated `/courses/:id/content` route for content management
|
|
||||||
- Comprehensive content CRUD operations (Create, Read, Update, Delete)
|
|
||||||
- Enhanced user interface with prominent action buttons
|
|
||||||
- Content type icons and visual indicators
|
|
||||||
- Content status management (published/unpublished, required/optional)
|
|
||||||
|
|
||||||
2. ENHANCED USER INTERFACE
|
|
||||||
---------------------------
|
|
||||||
- Prominent "Manage Content" button on course detail pages
|
|
||||||
- Improved visual hierarchy and button styling
|
|
||||||
- Better content organization and display
|
|
||||||
- Enhanced modal interfaces for content creation/editing
|
|
||||||
- Responsive design improvements
|
|
||||||
- Loading states and error handling
|
|
||||||
|
|
||||||
3. CONTENT MANAGEMENT WORKFLOW
|
|
||||||
------------------------------
|
|
||||||
- Add content with type-specific forms (Documents, Images, Videos, Articles, Quizzes, Certificates)
|
|
||||||
- Edit existing content with pre-populated forms
|
|
||||||
- Delete content with confirmation dialogs
|
|
||||||
- File upload with type validation and progress tracking
|
|
||||||
- Content ordering and points system
|
|
||||||
- Publishing controls and status indicators
|
|
||||||
|
|
||||||
4. NAVIGATION IMPROVEMENTS
|
|
||||||
---------------------------
|
|
||||||
- Direct access to content management from course detail pages
|
|
||||||
- Back navigation to course detail page
|
|
||||||
- Role-based visibility for content management features
|
|
||||||
- Clean separation between course editing and content management
|
|
||||||
|
|
||||||
NEW FEATURES IN v1.1.0:
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. COURSE CONTENT MANAGEMENT
|
|
||||||
----------------------------
|
|
||||||
- Multi-type content system (Documents, Images, Videos, Articles, Quizzes, Certificates)
|
|
||||||
- File upload with type validation and size limits
|
|
||||||
- Content ordering and publishing control
|
|
||||||
- Quiz system with multiple question types
|
|
||||||
- Points system for gamification
|
|
||||||
- Required/optional content marking
|
|
||||||
- Metadata storage for additional information
|
|
||||||
|
|
||||||
2. ENROLLMENT & SUBSCRIBER SYSTEM
|
|
||||||
---------------------------------
|
|
||||||
- Complete enrollment lifecycle management
|
|
||||||
- Status and payment tracking
|
|
||||||
- Progress monitoring (0-100%)
|
|
||||||
- Course capacity validation
|
|
||||||
- Certificate issuance tracking
|
|
||||||
- Enrollment analytics and statistics
|
|
||||||
- Role-based access control
|
|
||||||
|
|
||||||
3. ENHANCED API SYSTEM
|
|
||||||
-----------------------
|
|
||||||
- Course content management endpoints
|
|
||||||
- Enrollment management endpoints
|
|
||||||
- File upload with validation
|
|
||||||
- Quiz question management
|
|
||||||
- Progress tracking APIs
|
|
||||||
- Statistics and analytics endpoints
|
|
||||||
|
|
||||||
TECHNICAL IMPROVEMENTS:
|
|
||||||
=======================
|
|
||||||
|
|
||||||
1. ROUTING & NAVIGATION
|
|
||||||
------------------------
|
|
||||||
- Fixed homepage accessibility issues
|
|
||||||
- Proper route protection and redirection
|
|
||||||
- Nested routing with React Router v6
|
|
||||||
- Public and private route separation
|
|
||||||
- Role-based route access
|
|
||||||
|
|
||||||
2. API INTEGRATION
|
|
||||||
-------------------
|
|
||||||
- Axios-based API client with interceptors
|
|
||||||
- React Query for server state management
|
|
||||||
- Optimistic updates and caching
|
|
||||||
- Error handling and retry logic
|
|
||||||
- Request/response interceptors
|
|
||||||
- Course content API integration
|
|
||||||
- Enrollment management API integration
|
|
||||||
|
|
||||||
3. SECURITY ENHANCEMENTS
|
|
||||||
-------------------------
|
|
||||||
- JWT token management
|
|
||||||
- Password hashing and validation
|
|
||||||
- Role-based access control
|
|
||||||
- Input sanitization and validation
|
|
||||||
- CSRF protection considerations
|
|
||||||
|
|
||||||
4. PERFORMANCE OPTIMIZATIONS
|
|
||||||
-----------------------------
|
|
||||||
- React Query for efficient data fetching
|
|
||||||
- Pagination for large datasets
|
|
||||||
- Image optimization and compression
|
|
||||||
- Lazy loading considerations
|
|
||||||
- Caching strategies
|
|
||||||
- File upload optimization
|
|
||||||
- Content streaming for large files
|
|
||||||
|
|
||||||
BUG FIXES & RESOLUTIONS:
|
|
||||||
========================
|
|
||||||
|
|
||||||
1. CRITICAL FIXES
|
|
||||||
------------------
|
|
||||||
- Fixed homepage routing issue (shadowed routes)
|
|
||||||
- Resolved user creation API endpoint mismatch
|
|
||||||
- Fixed user data rendering issues (nested data structure)
|
|
||||||
- Corrected API base URL configuration
|
|
||||||
- Resolved modal rendering issues
|
|
||||||
|
|
||||||
2. ESLINT & CODE QUALITY
|
|
||||||
-------------------------
|
|
||||||
- Removed unused variables and imports
|
|
||||||
- Fixed accessibility warnings for anchor tags
|
|
||||||
- Resolved React child rendering issues
|
|
||||||
- Cleaned up console logs and debugging code
|
|
||||||
- Improved code organization and structure
|
|
||||||
|
|
||||||
3. USER EXPERIENCE FIXES
|
|
||||||
-------------------------
|
|
||||||
- Fixed non-functional Add/Edit/Delete buttons
|
|
||||||
- Resolved CSV import BOM issues
|
|
||||||
- Improved error message display
|
|
||||||
- Enhanced loading states and feedback
|
|
||||||
- Fixed modal accessibility issues
|
|
||||||
|
|
||||||
4. BACKEND STABILITY
|
|
||||||
---------------------
|
|
||||||
- Fixed user registration role validation
|
|
||||||
- Resolved password hashing issues
|
|
||||||
- Improved error handling and logging
|
|
||||||
- Fixed database connection issues
|
|
||||||
- Enhanced API response consistency
|
|
||||||
- Added comprehensive content validation
|
|
||||||
- Enhanced file upload error handling
|
|
||||||
|
|
||||||
DEPENDENCIES & TECHNOLOGIES:
|
|
||||||
============================
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- React 18.x
|
|
||||||
- React Router v6
|
|
||||||
- React Query (TanStack Query)
|
|
||||||
- Tailwind CSS
|
|
||||||
- Heroicons
|
|
||||||
- Axios
|
|
||||||
- React Hot Toast
|
|
||||||
- i18next & react-i18next
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- Node.js
|
|
||||||
- Express.js
|
|
||||||
- Sequelize ORM
|
|
||||||
- PostgreSQL
|
|
||||||
- JWT (jsonwebtoken)
|
|
||||||
- bcryptjs
|
|
||||||
- multer (enhanced for multi-type uploads)
|
|
||||||
- express-validator
|
|
||||||
- csv-parser
|
|
||||||
|
|
||||||
Development Tools:
|
|
||||||
- ESLint
|
|
||||||
- Prettier
|
|
||||||
- Nodemon
|
|
||||||
- Concurrently
|
|
||||||
|
|
||||||
FILE STRUCTURE:
|
|
||||||
==============
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- src/components/ (Reusable UI components)
|
|
||||||
- src/contexts/ (React Context providers)
|
|
||||||
- src/pages/ (Page components)
|
|
||||||
- src/services/ (API services)
|
|
||||||
- src/i18n/ (Internationalization)
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- routes/ (API route handlers)
|
|
||||||
- models/ (Database models)
|
|
||||||
- middleware/ (Custom middleware)
|
|
||||||
- config/ (Configuration files)
|
|
||||||
- uploads/ (File uploads)
|
|
||||||
- course-content/ (Course content management)
|
|
||||||
- enrollments/ (Enrollment management)
|
|
||||||
|
|
||||||
CONFIGURATION:
|
|
||||||
==============
|
|
||||||
|
|
||||||
Environment Variables:
|
|
||||||
- Database connection
|
|
||||||
- JWT secrets
|
|
||||||
- File upload paths
|
|
||||||
- API endpoints
|
|
||||||
- Development/production settings
|
|
||||||
|
|
||||||
Database Schema:
|
|
||||||
- Users table with role-based access
|
|
||||||
- Courses table with metadata
|
|
||||||
- CourseContent table with multi-type support
|
|
||||||
- QuizQuestion table for quiz functionality
|
|
||||||
- Enhanced Enrollments table with comprehensive tracking
|
|
||||||
- Attendance and assignment tables
|
|
||||||
|
|
||||||
SECURITY CONSIDERATIONS:
|
|
||||||
========================
|
|
||||||
|
|
||||||
- JWT token expiration and refresh
|
|
||||||
- Password complexity requirements
|
|
||||||
- Role-based access control
|
|
||||||
- Input validation and sanitization
|
|
||||||
- File upload security
|
|
||||||
- CORS configuration
|
|
||||||
- Environment variable protection
|
|
||||||
|
|
||||||
DEPLOYMENT READINESS:
|
|
||||||
=====================
|
|
||||||
|
|
||||||
- Environment-based configuration
|
|
||||||
- Database migration scripts
|
|
||||||
- File upload directory structure
|
|
||||||
- Error logging and monitoring
|
|
||||||
- Performance optimization
|
|
||||||
- Security hardening
|
|
||||||
|
|
||||||
This version (1.1.0) adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System, providing a complete foundation for creating rich educational content and managing student enrollments.
|
|
||||||
|
|
||||||
Release Date: [Current Date]
|
|
||||||
Version: 1.1.3
|
|
||||||
Status: Production Ready
|
|
||||||
Loading…
Reference in a new issue