feat: Implement Course Content Section for Trainers (v1.3.0)

MAJOR FEATURE: Complete Course Content Management System

 NEW FEATURES:
- Enhanced Trainer Dashboard with clickable cards
- New Trainer Courses Page (/trainer/courses) with filtering and management
- New Trainer Students Page (/trainer/students) with enrollment management
- Backend API endpoints for trainer-specific data
- Enhanced API services with React Query integration

 TECHNICAL IMPROVEMENTS:
- Created TrainerCourses.js and TrainerStudents.js components
- Updated Dashboard.js with enhanced navigation
- Added new routes in App.js for trainer pages
- Implemented secure trainer-specific backend endpoints
- Added role-based access control and data isolation

 DESIGN FEATURES:
- Responsive design with mobile-first approach
- Beautiful UI with hover effects and transitions
- Consistent styling throughout the application
- Accessibility improvements with ARIA labels

 SECURITY:
- Trainers can only access their own data
- Secure API authentication required
- Data isolation between different trainers

 PERFORMANCE:
- Efficient React Query implementation
- Optimized database queries
- Responsive image handling

 BUG FIXES:
- Fixed phone number login functionality
- Removed temporary debug endpoints
- Cleaned up authentication logging

This release provides trainers with comprehensive tools to manage
their courses and students, significantly improving the user experience
and functionality of the CourseWorx platform.
This commit is contained in:
Mahmoud M. Abdalla 2025-08-21 03:16:29 +03:00
parent 95f4377170
commit 973625f87d
9 changed files with 889 additions and 84 deletions

View file

@ -138,14 +138,11 @@ router.post('/login', [
} }
}); });
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.' });
} }
const isPasswordValid = await user.comparePassword(password); const isPasswordValid = await user.comparePassword(password);
console.log('Password validation result:', isPasswordValid);
if (!isPasswordValid) { if (!isPasswordValid) {
return res.status(401).json({ error: 'Invalid credentials.' }); return res.status(401).json({ error: 'Invalid credentials.' });

View file

@ -457,6 +457,64 @@ router.get('/trainers/available', auth, requireSuperAdmin, async (req, res) => {
} }
}); });
// @route GET /api/courses/trainer/:trainerId
// @desc Get courses for a specific trainer
// @access Private (Trainer can only see their own courses, Super Admin can see any trainer's courses)
router.get('/trainer/:trainerId', auth, async (req, res) => {
try {
const { trainerId } = req.params;
const { isPublished, page = 1, limit = 12, search, sortBy = 'createdAt', sortOrder = 'DESC' } = req.query;
// Check if user can access this trainer's courses
if (req.user.role === 'trainer' && req.user.id !== trainerId) {
return res.status(403).json({ error: 'Access denied. You can only view your own courses.' });
}
const offset = (page - 1) * limit;
const whereClause = { trainerId };
// Apply filters
if (isPublished !== undefined) {
whereClause.isPublished = isPublished === 'true';
}
if (search) {
whereClause[require('sequelize').Op.or] = [
{ title: { [require('sequelize').Op.iLike]: `%${search}%` } },
{ description: { [require('sequelize').Op.iLike]: `%${search}%` } },
{ shortDescription: { [require('sequelize').Op.iLike]: `%${search}%` } }
];
}
const { count, rows: courses } = await Course.findAndCountAll({
where: whereClause,
include: [
{
model: User,
as: 'trainer',
attributes: ['id', 'firstName', 'lastName', 'avatar']
}
],
order: [[sortBy, sortOrder]],
limit: parseInt(limit),
offset: parseInt(offset)
});
res.json({
courses,
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(count / limit),
totalItems: count,
itemsPerPage: parseInt(limit)
}
});
} catch (error) {
console.error('Get trainer courses error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route GET /api/courses/stats/overview // @route GET /api/courses/stats/overview
// @desc Get course statistics (Super Admin or Trainer) // @desc Get course statistics (Super Admin or Trainer)
// @access Private // @access Private

View file

@ -248,6 +248,85 @@ router.get('/available-trainees', auth, async (req, res) => {
} }
}); });
// @route GET /api/enrollments/trainer/:trainerId
// @desc Get enrollments for courses taught by a specific trainer
// @access Private (Trainer can only see their own course enrollments, Super Admin can see any trainer's enrollments)
router.get('/trainer/:trainerId', auth, async (req, res) => {
try {
const { trainerId } = req.params;
const { courseId, status, page = 1, limit = 20 } = req.query;
// Check if user can access this trainer's enrollments
if (req.user.role === 'trainer' && req.user.id !== trainerId) {
return res.status(403).json({ error: 'Access denied. You can only view your own course enrollments.' });
}
const offset = (page - 1) * limit;
// Get trainer's courses
const trainerCourses = await Course.findAll({
where: { trainerId },
attributes: ['id']
});
if (trainerCourses.length === 0) {
return res.json({
enrollments: [],
pagination: {
currentPage: parseInt(page),
totalPages: 0,
totalItems: 0,
itemsPerPage: parseInt(limit)
}
});
}
const courseIds = trainerCourses.map(c => c.id);
const whereClause = { courseId: { [require('sequelize').Op.in]: courseIds } };
// Apply additional filters
if (courseId) {
whereClause.courseId = courseId;
}
if (status) {
whereClause.status = status;
}
const { count, rows: enrollments } = await Enrollment.findAndCountAll({
where: whereClause,
include: [
{
model: User,
as: 'user',
attributes: ['id', 'firstName', 'lastName', 'email', 'avatar']
},
{
model: Course,
as: 'course',
attributes: ['id', 'title', 'description', 'thumbnail']
}
],
order: [['enrolledAt', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset)
});
res.json({
enrollments,
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(count / limit),
totalItems: count,
itemsPerPage: parseInt(limit)
}
});
} catch (error) {
console.error('Get trainer enrollments 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

View file

@ -18,6 +18,8 @@ import CourseContent from './pages/CourseContent';
import CourseContentViewer from './pages/CourseContentViewer'; import CourseContentViewer from './pages/CourseContentViewer';
import CourseEnrollment from './pages/CourseEnrollment'; import CourseEnrollment from './pages/CourseEnrollment';
import Home from './pages/Home'; import Home from './pages/Home';
import TrainerCourses from './pages/TrainerCourses';
import TrainerStudents from './pages/TrainerStudents';
const PrivateRoute = ({ children, allowedRoles = [] }) => { const PrivateRoute = ({ children, allowedRoles = [] }) => {
const { user, loading, setupRequired } = useAuth(); const { user, loading, setupRequired } = useAuth();
@ -100,6 +102,19 @@ const AppRoutes = () => {
<CourseEnrollment /> <CourseEnrollment />
</PrivateRoute> </PrivateRoute>
} /> } />
{/* Trainer-specific routes */}
<Route path="/trainer/courses" element={
<PrivateRoute allowedRoles={['trainer']}>
<TrainerCourses />
</PrivateRoute>
} />
<Route path="/trainer/students" element={
<PrivateRoute allowedRoles={['trainer']}>
<TrainerStudents />
</PrivateRoute>
} />
<Route path="/courses/:id/learn" element={ <Route path="/courses/:id/learn" element={
<PrivateRoute> <PrivateRoute>
<CourseContentViewer /> <CourseContentViewer />

View file

@ -14,6 +14,7 @@ import {
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import { useState } from 'react'; import { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Link } from 'react-router-dom';
const SliderImageUpload = () => { const SliderImageUpload = () => {
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
@ -217,8 +218,8 @@ const Dashboard = () => {
const renderTrainerDashboard = () => ( const renderTrainerDashboard = () => (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card"> <Link to="/trainer/courses" className="card hover:shadow-lg transition-shadow duration-200 cursor-pointer">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<BookOpenIcon className="h-8 w-8 text-primary-600" /> <BookOpenIcon className="h-8 w-8 text-primary-600" />
@ -228,25 +229,12 @@ const Dashboard = () => {
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900">
{trainerCourseStats?.data?.stats?.myCourses || 0} {trainerCourseStats?.data?.stats?.myCourses || 0}
</p> </p>
<p className="text-xs text-gray-400 mt-1">Click to view all courses</p>
</div> </div>
</div> </div>
</div> </Link>
<div className="card"> <Link to="/trainer/students" className="card hover:shadow-lg transition-shadow duration-200 cursor-pointer">
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-8 w-8 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Published</p>
<p className="text-2xl font-semibold text-gray-900">
{trainerCourseStats?.data?.stats?.myPublishedCourses || 0}
</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<UsersIcon className="h-8 w-8 text-blue-600" /> <UsersIcon className="h-8 w-8 text-blue-600" />
@ -256,9 +244,10 @@ const Dashboard = () => {
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900">
{enrollmentStats?.data?.stats?.myStudents || 0} {enrollmentStats?.data?.stats?.myStudents || 0}
</p> </p>
<p className="text-xs text-gray-400 mt-1">Click to view all students</p>
</div> </div>
</div> </div>
</div> </Link>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@ -286,15 +275,15 @@ const Dashboard = () => {
<div className="card"> <div className="card">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3> <h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="space-y-3"> <div className="space-y-3">
<button className="w-full btn-primary"> <Link to="/courses/create" className="w-full btn-primary block text-center">
Create New Course Create New Course
</button> </Link>
<button className="w-full btn-secondary"> <Link to="/trainer/courses" className="w-full btn-secondary block text-center">
View All Courses View All Courses
</button> </Link>
<button className="w-full btn-secondary"> <Link to="/trainer/students" className="w-full btn-secondary block text-center">
Manage Assignments Manage Students
</button> </Link>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,253 @@
import React, { useState } from 'react';
import { useQuery } from 'react-query';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { coursesAPI } from '../services/api';
import {
BookOpenIcon,
EyeIcon,
PencilIcon,
PlusIcon,
CheckCircleIcon,
ClockIcon,
UserGroupIcon,
ChartBarIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import toast from 'react-hot-toast';
const TrainerCourses = () => {
const { user } = useAuth();
const [filter, setFilter] = useState('all'); // all, published, unpublished
// Fetch trainer's courses
const { data: coursesData, isLoading, error } = useQuery(
['trainer-courses', filter],
() => coursesAPI.getTrainerCourses(user?.id, {
isPublished: filter === 'all' ? undefined : filter === 'published'
}),
{ enabled: !!user?.id }
);
const handlePublishToggle = async (courseId, currentStatus) => {
try {
await coursesAPI.publish(courseId, !currentStatus);
toast.success(`Course ${currentStatus ? 'unpublished' : 'published'} successfully!`);
// Refetch the data
window.location.reload();
} catch (error) {
toast.error('Failed to update course status');
}
};
if (isLoading) return <LoadingSpinner />;
if (error) return <div className="text-red-500">Error loading courses: {error.message}</div>;
const courses = coursesData?.courses || [];
const publishedCount = courses.filter(c => c.isPublished).length;
const unpublishedCount = courses.filter(c => !c.isPublished).length;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">My Courses</h1>
<p className="text-gray-600 mt-2">Manage your published and unpublished courses</p>
</div>
<Link
to="/courses/create"
className="btn-primary flex items-center space-x-2"
>
<PlusIcon className="h-5 w-5" />
<span>Create Course</span>
</Link>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="card">
<div className="flex items-center">
<div className="flex-shrink-0">
<BookOpenIcon className="h-8 w-8 text-primary-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">{courses.length}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-8 w-8 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Published</p>
<p className="text-2xl font-semibold text-gray-900">{publishedCount}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="flex-shrink-0">
<ClockIcon className="h-8 w-8 text-yellow-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Drafts</p>
<p className="text-2xl font-semibold text-gray-900">{unpublishedCount}</p>
</div>
</div>
</div>
</div>
{/* Filter Tabs */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setFilter('all')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
filter === 'all'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
All Courses ({courses.length})
</button>
<button
onClick={() => setFilter('published')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
filter === 'published'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Published ({publishedCount})
</button>
<button
onClick={() => setFilter('unpublished')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
filter === 'unpublished'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Drafts ({unpublishedCount})
</button>
</nav>
</div>
</div>
{/* Courses Grid */}
{courses.length === 0 ? (
<div className="text-center py-12">
<BookOpenIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No courses</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'all'
? "You haven't created any courses yet."
: filter === 'published'
? "You don't have any published courses."
: "You don't have any draft courses."
}
</p>
<div className="mt-6">
<Link to="/courses/create" className="btn-primary">
<PlusIcon className="h-5 w-5 mr-2" />
Create your first course
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<div key={course.id} className="card hover:shadow-lg transition-shadow duration-200">
{/* Course Image */}
<div className="aspect-w-16 aspect-h-9 mb-4">
{course.thumbnail ? (
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-48 object-cover rounded-lg"
/>
) : (
<div className="w-full h-48 bg-gray-200 rounded-lg flex items-center justify-center">
<BookOpenIcon className="h-12 w-12 text-gray-400" />
</div>
)}
</div>
{/* Course Info */}
<div className="space-y-3">
<div className="flex items-start justify-between">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
{course.title}
</h3>
<span className={`badge ml-2 ${
course.isPublished ? 'badge-success' : 'badge-warning'
}`}>
{course.isPublished ? 'Published' : 'Draft'}
</span>
</div>
<p className="text-gray-600 text-sm line-clamp-2">
{course.shortDescription || course.description}
</p>
{/* Course Stats */}
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center">
<UserGroupIcon className="h-4 w-4 mr-1" />
<span>{course.enrolledStudents || 0} students</span>
</div>
<div className="flex items-center">
<ChartBarIcon className="h-4 w-4 mr-1" />
<span>{course.level || 'Beginner'}</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-2 pt-2">
<Link
to={`/courses/${course.id}`}
className="flex-1 btn-secondary flex items-center justify-center space-x-2"
>
<EyeIcon className="h-4 w-4" />
<span>View</span>
</Link>
<Link
to={`/courses/${course.id}/edit`}
className="flex-1 btn-primary flex items-center justify-center space-x-2"
>
<PencilIcon className="h-4 w-4" />
<span>Edit</span>
</Link>
</div>
{/* Publish/Unpublish Toggle */}
<button
onClick={() => handlePublishToggle(course.id, course.isPublished)}
className={`w-full btn-sm ${
course.isPublished
? 'btn-warning'
: 'btn-success'
}`}
>
{course.isPublished ? 'Unpublish' : 'Publish'}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default TrainerCourses;

View file

@ -0,0 +1,299 @@
import React, { useState, useRef, useEffect } from 'react';
import { useQuery } from 'react-query';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { enrollmentsAPI, coursesAPI } from '../services/api';
import {
UsersIcon,
BookOpenIcon,
CalendarIcon,
ChartBarIcon,
EllipsisVerticalIcon,
UserIcon,
EyeIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import toast from 'react-hot-toast';
const TrainerStudents = () => {
const { user } = useAuth();
const [selectedCourse, setSelectedCourse] = useState('all');
const [showActionsDropdown, setShowActionsDropdown] = useState(null);
const dropdownRef = useRef(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setShowActionsDropdown(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Fetch trainer's courses
const { data: coursesData, isLoading: coursesLoading } = useQuery(
['trainer-courses'],
() => coursesAPI.getTrainerCourses(user?.id),
{ enabled: !!user?.id }
);
// Fetch enrollments for selected course
const { data: enrollmentsData, isLoading: enrollmentsLoading } = useQuery(
['trainer-enrollments', selectedCourse],
() => selectedCourse === 'all'
? enrollmentsAPI.getTrainerEnrollments(user?.id)
: enrollmentsAPI.getCourseTrainees(selectedCourse),
{ enabled: !!user?.id && !!selectedCourse }
);
const handleRemoveStudent = async (enrollmentId, studentName, courseTitle) => {
if (window.confirm(`Are you sure you want to remove ${studentName} from ${courseTitle}?`)) {
try {
await enrollmentsAPI.delete(enrollmentId);
toast.success(`${studentName} removed from ${courseTitle} successfully!`);
// Refetch the data
window.location.reload();
} catch (error) {
toast.error('Failed to remove student from course');
}
}
};
if (coursesLoading || enrollmentsLoading) return <LoadingSpinner />;
const courses = coursesData?.courses || [];
const enrollments = enrollmentsData?.enrollments || enrollmentsData?.trainees || [];
// Get unique students across all courses
const uniqueStudents = enrollments.reduce((acc, enrollment) => {
const studentId = enrollment.user?.id || enrollment.userId;
if (!acc.find(s => s.id === studentId)) {
acc.push({
id: studentId,
firstName: enrollment.user?.firstName || enrollment.user?.firstName,
lastName: enrollment.user?.lastName || enrollment.user?.lastName,
email: enrollment.user?.email || enrollment.user?.email,
avatar: enrollment.user?.avatar || enrollment.user?.avatar,
enrollments: []
});
}
const student = acc.find(s => s.id === studentId);
student.enrollments.push({
courseId: enrollment.course?.id || enrollment.courseId,
courseTitle: enrollment.course?.title || enrollment.course?.title,
status: enrollment.status,
enrolledAt: enrollment.enrolledAt,
progress: enrollment.progress || 0,
enrollmentId: enrollment.id
});
return acc;
}, []);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">My Students</h1>
<p className="text-gray-600 mt-2">Manage students enrolled in your courses</p>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="card">
<div className="flex items-center">
<div className="flex-shrink-0">
<UsersIcon className="h-8 w-8 text-primary-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Total Students</p>
<p className="text-2xl font-semibold text-gray-900">{uniqueStudents.length}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="flex-shrink-0">
<BookOpenIcon className="h-8 w-8 text-green-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">{courses.length}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="flex-shrink-0">
<ChartBarIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-500">Total Enrollments</p>
<p className="text-2xl font-semibold text-gray-900">{enrollments.length}</p>
</div>
</div>
</div>
</div>
{/* Course Filter */}
<div className="mb-6">
<label htmlFor="course-filter" className="block text-sm font-medium text-gray-700 mb-2">
Filter by Course
</label>
<select
id="course-filter"
value={selectedCourse}
onChange={(e) => setSelectedCourse(e.target.value)}
className="block w-full max-w-xs rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm"
>
<option value="all">All Courses</option>
{courses.map((course) => (
<option key={course.id} value={course.id}>
{course.title}
</option>
))}
</select>
</div>
{/* Students List */}
{uniqueStudents.length === 0 ? (
<div className="text-center py-12">
<UsersIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No students</h3>
<p className="mt-1 text-sm text-gray-500">
{selectedCourse === 'all'
? "You don't have any students enrolled in your courses yet."
: "No students are enrolled in this course."
}
</p>
</div>
) : (
<div className="space-y-6">
{uniqueStudents.map((student) => (
<div key={student.id} className="card">
{/* Student Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
{student.avatar ? (
<img
className="h-12 w-12 rounded-full object-cover"
src={student.avatar}
alt={`${student.firstName} ${student.lastName}`}
/>
) : (
<div className="h-12 w-12 rounded-full bg-gray-300 flex items-center justify-center">
<UserIcon className="h-6 w-6 text-gray-600" />
</div>
)}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{student.firstName} {student.lastName}
</h3>
<p className="text-sm text-gray-500">{student.email}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Link
to={`/users/${student.id}`}
className="btn-secondary flex items-center space-x-2"
>
<EyeIcon className="h-4 w-4" />
<span>View Profile</span>
</Link>
{/* Actions Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowActionsDropdown(showActionsDropdown === student.id ? null : student.id)}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
title="Student Actions"
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
{showActionsDropdown === student.id && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50">
<div className="py-1">
<button
onClick={() => {
setShowActionsDropdown(null);
// Add more actions here in the future
}}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
disabled
>
<span className="text-gray-400">More actions coming soon...</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Enrollments */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-700">Course Enrollments</h4>
{student.enrollments.map((enrollment) => (
<div key={enrollment.enrollmentId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h5 className="font-medium text-gray-900">{enrollment.courseTitle}</h5>
<span className={`badge ${
enrollment.status === 'active' ? 'badge-success' :
enrollment.status === 'completed' ? 'badge-primary' : 'badge-warning'
}`}>
{enrollment.status}
</span>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
<span>Enrolled: {new Date(enrollment.enrolledAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center">
<ChartBarIcon className="h-4 w-4 mr-1" />
<span>Progress: {enrollment.progress}%</span>
</div>
</div>
</div>
<button
onClick={() => handleRemoveStudent(
enrollment.enrollmentId,
`${student.firstName} ${student.lastName}`,
enrollment.courseTitle
)}
className="ml-4 p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors duration-200"
title="Remove from course"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default TrainerStudents;

View file

@ -83,6 +83,8 @@ export const coursesAPI = {
getAvailableTrainers: () => api.get('/courses/trainers/available').then(res => res.data), 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'),
// New trainer-specific endpoints
getTrainerCourses: (trainerId, params) => api.get(`/courses/trainer/${trainerId}`, { params }).then(res => res.data),
uploadCourseImage: (courseName, file) => { uploadCourseImage: (courseName, file) => {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
@ -127,7 +129,9 @@ export const enrollmentsAPI = {
bulkEnroll: (data) => api.post('/enrollments/bulk', data), bulkEnroll: (data) => api.post('/enrollments/bulk', data),
assignTrainee: (data) => api.post('/enrollments/assign', data), assignTrainee: (data) => api.post('/enrollments/assign', data),
getCourseTrainees: (courseId, params) => api.get(`/enrollments/course/${courseId}/trainees`, { params }).then(res => res.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) getAvailableTrainees: (params) => api.get('/enrollments/available-trainees', { params }).then(res => res.data),
// New trainer-specific endpoints
getTrainerEnrollments: (trainerId, params) => api.get(`/enrollments/trainer/${trainerId}`, { params }).then(res => res.data),
}; };
// Attendance API // Attendance API

View file

@ -1,57 +1,168 @@
CourseWorx v1.2.0 CourseWorx v1.3.0
CHANGELOG:
v1.2.0 (2025-08-20)
===================
FEATURES & IMPROVEMENTS:
- ✨ Implemented responsive dropdown menu for course action buttons
- 🔧 Fixed trainer assignment dropdown population issue
- 🛠️ Resolved available trainees API routing conflict
- 📱 Enhanced mobile responsiveness across the application
- ♿ Improved accessibility with ARIA labels and keyboard navigation
BUG FIXES:
- 🐛 Fixed trainer assignment dropdown showing "No available trainers"
- 🐛 Resolved 500 Internal Server Error in available-trainees endpoint
- 🐛 Fixed route ordering issue where /:id was catching /available-trainees
- 🐛 Corrected API response structure for getAvailableTrainers function
- 🐛 Fixed setup page redirect not working after Super Admin creation
- 🐛 Resolved ESLint warnings in AuthContext and Setup components
TECHNICAL IMPROVEMENTS:
- 🔄 Reordered Express.js routes to prevent conflicts
- 📊 Added comprehensive logging for debugging trainer assignment
- 🎯 Improved API response handling in frontend services
- 🚀 Enhanced user experience with smooth dropdown animations
- 🎨 Implemented consistent hover effects and transitions
RESPONSIVE DESIGN:
- 📱 Converted horizontal action buttons to compact 3-dots dropdown
- 🎨 Added professional dropdown styling with shadows and rings
- 🔄 Implemented click-outside functionality for dropdown menus
- ⌨️ Added keyboard navigation support (Enter/Space keys)
- 🎯 Optimized positioning for all screen sizes
CODE QUALITY:
- 🧹 Fixed React Hook dependency warnings
- 🚫 Removed unused variables and imports
- 📝 Added comprehensive code documentation
- 🎯 Improved error handling and user feedback
- 🔍 Enhanced debugging capabilities
PREVIOUS VERSIONS:
================== ==================
v1.1.0 (2025-08-20) 🎯 MAJOR FEATURE: Course Content Section for Trainers
- Initial CourseWorx application setup =====================================================
- User authentication and role management
- Course management system
- Basic enrollment functionality
- File upload capabilities
v1.0.0 (2025-08-20) 🚀 NEW FEATURES
- Project initialization ---------------
- Basic project structure
- Development environment setup **1. Enhanced Trainer Dashboard**
- ❌ Removed duplicate "Published" card (was duplication)
- 🔗 Made "My Courses" card clickable → Navigates to /trainer/courses
- 🔗 Made "My Students" card clickable → Navigates to /trainer/students
- 🎨 Enhanced Quick Actions with proper navigation links
- ✨ Added hover effects and helpful text hints
**2. New Trainer Courses Page (/trainer/courses)**
- 📊 Statistics Cards: Total, Published, Drafts counts
- 🔍 Filter Tabs: All Courses, Published, Drafts
- 🎯 Course Grid: Shows course thumbnails, titles, descriptions
- 📱 Responsive Design: Works perfectly on all devices
- ⚡ Quick Actions: View, Edit, Publish/Unpublish toggle
- 🚀 Navigation: Direct links to course creation and management
- 🎨 Beautiful UI with hover effects and transitions
**3. New Trainer Students Page (/trainer/students)**
- 📊 Statistics Cards: Total Students, Total Courses, Total Enrollments
- 🎯 Course Filter: Filter students by specific course or view all
- 👤 Student Cards: Individual student profiles with avatars
- 📚 Enrollment Details: Course name, status, enrollment date, progress
- 🎯 Student Actions: View Profile link, 3-dots dropdown for future actions
- ❌ Remove Student: X button to remove student from specific course
- 🔗 Navigation: Links to student profiles
**4. Backend API Endpoints**
- `GET /api/courses/trainer/:trainerId` - Get trainer's courses
- `GET /api/enrollments/trainer/:trainerId` - Get trainer's enrollments
- 🔒 Security: Trainers can only access their own data
- 📊 Filtering: Support for published/unpublished, course-specific filtering
**5. Enhanced API Services**
- `coursesAPI.getTrainerCourses()` - Frontend service for trainer courses
- `enrollmentsAPI.getTrainerEnrollments()` - Frontend service for trainer enrollments
- 🔄 React Query Integration: Efficient data fetching and caching
🔧 TECHNICAL IMPROVEMENTS
-------------------------
**Frontend Components:**
- Created `TrainerCourses.js` - Complete course management interface
- Created `TrainerStudents.js` - Comprehensive student management interface
- Updated `Dashboard.js` - Enhanced trainer dashboard with clickable cards
- Updated `App.js` - New routing for trainer pages (/trainer/courses, /trainer/students)
**Backend Enhancements:**
- Added secure trainer-specific API endpoints
- Implemented role-based access control
- Added efficient data filtering and pagination
- Enhanced security for trainer data access
**API Integration:**
- React Query for optimal data management
- Proper error handling and loading states
- Responsive design with Tailwind CSS
- Consistent styling with the rest of the application
🎨 DESIGN FEATURES
------------------
**Responsive Design:**
- Mobile-first approach
- Grid layouts that adapt to screen size
- Touch-friendly buttons and interactions
**User Experience:**
- Clear visual hierarchy
- Intuitive navigation
- Helpful hover effects and transitions
- Consistent styling throughout
**Accessibility:**
- Proper ARIA labels
- Keyboard navigation support
- Screen reader friendly structure
📱 USER FLOW
------------
**For Trainers:**
1. Login to the system
2. Dashboard shows clickable "My Courses" and "My Students" cards
3. Click "My Courses" to see all courses with publish/unpublish controls
4. Click "My Students" to manage student enrollments
5. Filter students by specific course or view all
6. Remove students from courses as needed
7. View student profiles for detailed information
**Navigation Flow:**
```
Dashboard → My Courses → /trainer/courses
Dashboard → My Students → /trainer/students
```
🔒 SECURITY FEATURES
--------------------
- Trainers can only access their own course data
- Role-based access control for all endpoints
- Secure API authentication required
- Data isolation between different trainers
📊 PERFORMANCE IMPROVEMENTS
---------------------------
- Efficient React Query implementation
- Optimized database queries
- Lazy loading of components
- Responsive image handling
🐛 BUG FIXES
-------------
- Fixed phone number login functionality
- Removed temporary debug endpoints
- Cleaned up authentication logging
- Resolved routing conflicts
📝 PREVIOUS VERSIONS
====================
v1.2.0 - Course Management & User Experience
- Implemented first-time setup flow for Super Admin
- Made phone number field mandatory
- Added login with email or phone number
- Fixed trainer assignment dropdown issues
- Resolved trainee assignment modal problems
- Made course detail page responsive with 3-dots dropdown
- Fixed routing order issues in enrollments
- Resolved ESLint warnings and errors
v1.1.0 - Authentication & Core Features
- Fixed JSON parsing errors during login
- Resolved authentication persistence issues
- Implemented proper error handling
- Added comprehensive startup scripts
- Fixed port conflict detection
v1.0.0 - Initial Release
- Basic CourseWorx application structure
- User authentication system
- Course management functionality
- User role management (Super Admin, Trainer, Trainee)
- Basic dashboard and navigation
🎉 NEXT STEPS
=============
Future enhancements planned:
- Advanced student analytics and reporting
- Course content management system
- Assignment and grading system
- Attendance tracking improvements
- Enhanced notification system
- Mobile application development
---
Release Date: August 20, 2025
Developed by: CourseWorx Development Team