v1.1.2: Add trainer assignment system and navigation improvements

- Add trainer assignment functionality for Super Admins
- Move logout/profile to top-right user dropdown
- Clean up sidebar navigation (Dashboard, Courses, Users only)
- Add comprehensive debugging for trainer assignment
- Improve user interface with modern dropdown menu
- Add role-based access control for trainer assignment
- Update version.txt with new features and version bump
This commit is contained in:
Mahmoud M. Abdalla 2025-07-28 21:23:09 +03:00
parent cee2aaf713
commit 6cc071c8d4
12 changed files with 1498 additions and 313 deletions

View file

@ -374,6 +374,88 @@ router.get('/categories/all', async (req, res) => {
}
});
// @route PUT /api/courses/:id/assign-trainer
// @desc Assign trainer to course (Super Admin only)
// @access Private (Super Admin)
router.put('/:id/assign-trainer', [
auth,
requireSuperAdmin,
body('trainerId').isUUID().withMessage('Valid trainer ID is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const course = await Course.findByPk(req.params.id);
if (!course) {
return res.status(404).json({ error: 'Course not found.' });
}
const { trainerId } = req.body;
// Verify the trainer exists and is actually a trainer
const trainer = await User.findByPk(trainerId);
if (!trainer) {
return res.status(404).json({ error: 'Trainer not found.' });
}
if (trainer.role !== 'trainer') {
return res.status(400).json({ error: 'Selected user is not a trainer.' });
}
if (!trainer.isActive) {
return res.status(400).json({ error: 'Selected trainer account is inactive.' });
}
// Update the course with the new trainer
await course.update({ trainerId });
const updatedCourse = await Course.findByPk(course.id, {
include: [
{
model: User,
as: 'trainer',
attributes: ['id', 'firstName', 'lastName', 'avatar', 'email']
}
]
});
res.json({
message: 'Trainer assigned successfully.',
course: updatedCourse
});
} catch (error) {
console.error('Assign trainer error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route GET /api/courses/trainers/available
// @desc Get available trainers for assignment (Super Admin only)
// @access Private (Super Admin)
router.get('/trainers/available', auth, requireSuperAdmin, async (req, res) => {
try {
console.log('Get available trainers request from user:', req.user.id, 'role:', req.user.role);
const trainers = await User.findAll({
where: {
role: 'trainer',
isActive: true
},
attributes: ['id', 'firstName', 'lastName', 'email', 'avatar'],
order: [['firstName', 'ASC'], ['lastName', 'ASC']]
});
console.log('Found trainers:', trainers.length);
res.json({ trainers });
} catch (error) {
console.error('Get available trainers error:', error);
res.status(500).json({ error: 'Server error.' });
}
});
// @route GET /api/courses/stats/overview
// @desc Get course statistics (Super Admin or Trainer)
// @access Private

View file

@ -13,6 +13,7 @@ import Layout from './components/Layout';
import LoadingSpinner from './components/LoadingSpinner';
import CourseEdit from './pages/CourseEdit';
import CourseContent from './pages/CourseContent';
import CourseContentViewer from './pages/CourseContentViewer';
import Home from './pages/Home';
const PrivateRoute = ({ children, allowedRoles = [] }) => {
@ -64,6 +65,11 @@ const AppRoutes = () => {
<CourseContent />
</PrivateRoute>
} />
<Route path="/courses/:id/learn" element={
<PrivateRoute>
<CourseContentViewer />
</PrivateRoute>
} />
<Route path="/users/import" element={
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
<UserImport />

View file

@ -10,6 +10,7 @@ import {
Bars3Icon,
XMarkIcon,
ArrowRightOnRectangleIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
const Layout = () => {
@ -18,6 +19,7 @@ const Layout = () => {
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
// Check if user requires password change
useEffect(() => {
@ -26,11 +28,24 @@ const Layout = () => {
}
}, [user?.requiresPasswordChange]);
// Close user menu when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (userMenuOpen && !event.target.closest('.user-menu')) {
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [userMenuOpen]);
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Courses', href: '/courses', icon: AcademicCapIcon },
...(isSuperAdmin ? [{ name: 'Users', href: '/users', icon: UsersIcon }] : []),
{ name: 'Profile', href: '/profile', icon: UserIcon },
];
const handleLogout = () => {
@ -71,30 +86,6 @@ const Layout = () => {
</a>
))}
</nav>
<div className="border-t border-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-primary-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
</span>
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-gray-500 capitalize">{user?.role?.replace('_', ' ')}</p>
</div>
</div>
<button
onClick={handleLogout}
className="mt-3 flex w-full items-center px-2 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-md"
>
<ArrowRightOnRectangleIcon className="mr-3 h-5 w-5" />
Logout
</button>
</div>
</div>
</div>
@ -120,30 +111,6 @@ const Layout = () => {
</a>
))}
</nav>
<div className="border-t border-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-primary-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
</span>
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-gray-500 capitalize">{user?.role?.replace('_', ' ')}</p>
</div>
</div>
<button
onClick={handleLogout}
className="mt-3 flex w-full items-center px-2 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-md"
>
<ArrowRightOnRectangleIcon className="mr-3 h-5 w-5" />
Logout
</button>
</div>
</div>
</div>
@ -161,11 +128,55 @@ const Layout = () => {
<div className="flex flex-1"></div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
<div className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200" />
<div className="flex items-center gap-x-4">
<span className="text-sm text-gray-500">
Welcome back, {user?.firstName}!
{/* User dropdown menu */}
<div className="relative user-menu">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center space-x-3 text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<div className="h-8 w-8 rounded-full bg-primary-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
</span>
</div>
<div className="hidden md:block text-left">
<p className="text-sm font-medium text-gray-700">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-gray-500 capitalize">{user?.role?.replace('_', ' ')}</p>
</div>
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
</button>
{/* Dropdown menu */}
{userMenuOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
<div className="py-1" role="menu" aria-orientation="vertical">
<a
href="/profile"
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="menuitem"
onClick={() => setUserMenuOpen(false)}
>
<UserIcon className="mr-3 h-4 w-4" />
Profile
</a>
<button
onClick={() => {
handleLogout();
setUserMenuOpen(false);
}}
className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="menuitem"
>
<ArrowRightOnRectangleIcon className="mr-3 h-4 w-4" />
Logout
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { coursesAPI } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import {
XMarkIcon,
UserIcon,
AcademicCapIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from './LoadingSpinner';
import toast from 'react-hot-toast';
const TrainerAssignmentModal = ({ isOpen, onClose, courseId, currentTrainer }) => {
const [selectedTrainerId, setSelectedTrainerId] = useState('');
const queryClient = useQueryClient();
const { user } = useAuth();
// Get available trainers
const { data: trainersData, isLoading: trainersLoading, error: trainersError } = useQuery(
['available-trainers'],
() => {
console.log('Fetching available trainers...');
return coursesAPI.getAvailableTrainers();
},
{
enabled: isOpen,
onError: (error) => {
console.error('Trainer loading error:', error);
toast.error('Failed to load trainers');
},
onSuccess: (data) => {
console.log('Trainers loaded successfully:', data);
}
}
);
// Assign trainer mutation
const assignTrainerMutation = useMutation(
({ courseId, trainerId }) => coursesAPI.assignTrainer(courseId, trainerId),
{
onSuccess: () => {
queryClient.invalidateQueries(['courses']);
queryClient.invalidateQueries(['course', courseId]);
toast.success('Trainer assigned successfully!');
onClose();
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to assign trainer');
}
}
);
const handleAssignTrainer = () => {
if (!selectedTrainerId) {
toast.error('Please select a trainer');
return;
}
assignTrainerMutation.mutate({ courseId, trainerId: selectedTrainerId });
};
if (!isOpen) return null;
// Check if user is Super Admin
if (user?.role !== 'super_admin') {
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="text-center">
<h3 className="text-lg font-medium text-gray-900 mb-4">Access Denied</h3>
<p className="text-sm text-gray-600 mb-4">
Only Super Admins can assign trainers to courses.
</p>
<button
onClick={onClose}
className="btn-primary"
>
Close
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<AcademicCapIcon className="h-6 w-6 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900">
Assign Trainer
</h3>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{currentTrainer && (
<div className="mb-4 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>Current Trainer:</strong> {currentTrainer.firstName} {currentTrainer.lastName}
</p>
</div>
)}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Trainer
</label>
{trainersLoading ? (
<div className="flex justify-center py-4">
<LoadingSpinner size="sm" />
</div>
) : (
<select
value={selectedTrainerId}
onChange={(e) => setSelectedTrainerId(e.target.value)}
className="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"
>
<option value="">Select a trainer...</option>
{trainersData?.trainers?.map((trainer) => (
<option key={trainer.id} value={trainer.id}>
{trainer.firstName} {trainer.lastName} ({trainer.email})
</option>
))}
</select>
)}
</div>
{trainersError && (
<div className="mb-4 p-3 bg-red-50 rounded-md">
<p className="text-sm text-red-800">
Error loading trainers: {trainersError.message}
</p>
</div>
)}
{trainersData?.trainers?.length === 0 && !trainersLoading && !trainersError && (
<div className="mb-4 p-3 bg-yellow-50 rounded-md">
<p className="text-sm text-yellow-800">
No active trainers available. Please create trainer accounts first.
</p>
</div>
)}
{/* Debug info - remove in production */}
{process.env.NODE_ENV === 'development' && (
<div className="mb-4 p-2 bg-gray-100 rounded text-xs">
<p>Debug: trainersData = {JSON.stringify(trainersData, null, 2)}</p>
<p>Debug: trainersLoading = {trainersLoading}</p>
<p>Debug: trainersError = {trainersError?.message}</p>
<p>Debug: Current user role = {user?.role}</p>
<p>Debug: Current user ID = {user?.id}</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
Cancel
</button>
<button
onClick={handleAssignTrainer}
disabled={!selectedTrainerId || assignTrainerMutation.isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md 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"
>
{assignTrainerMutation.isLoading ? (
<LoadingSpinner size="sm" />
) : (
'Assign Trainer'
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default TrainerAssignmentModal;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,480 @@
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, courseContentAPI } from '../services/api';
import {
ArrowLeftIcon,
DocumentIcon,
PhotoIcon,
VideoCameraIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
AcademicCapIcon as CertificateIcon,
CheckIcon,
XMarkIcon,
EyeIcon,
ArrowDownTrayIcon as DownloadIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import toast from 'react-hot-toast';
const CourseContentViewer = () => {
const { id } = useParams();
const queryClient = useQueryClient();
const [selectedContent, setSelectedContent] = useState(null);
const [quizAnswers, setQuizAnswers] = useState({});
const [quizResults, setQuizResults] = useState({});
const [videoProgress, setVideoProgress] = useState({});
// Get course details
const { data: courseData, isLoading: courseLoading } = useQuery(
['course', id],
() => coursesAPI.getById(id),
{ enabled: !!id }
);
// Get course content
const { data: contentData, isLoading: contentLoading } = useQuery(
['course-content', id],
() => courseContentAPI.getAll(id),
{ enabled: !!id }
);
// Quiz submission mutation
const submitQuizMutation = useMutation(
(data) => courseContentAPI.submitQuiz(id, selectedContent.id, data),
{
onSuccess: (data) => {
setQuizResults(data.results);
toast.success('Quiz submitted successfully!');
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to submit quiz');
},
}
);
const handleQuizAnswer = (questionId, answer) => {
setQuizAnswers(prev => ({
...prev,
[questionId]: answer
}));
};
const handleQuizSubmit = () => {
if (!selectedContent) return;
submitQuizMutation.mutate({
answers: quizAnswers
});
};
const handleVideoProgress = (contentId, progress) => {
setVideoProgress(prev => ({
...prev,
[contentId]: progress
}));
};
const getContentTypeIcon = (type) => {
const icons = {
document: DocumentIcon,
image: PhotoIcon,
video: VideoCameraIcon,
article: DocumentTextIcon,
quiz: QuestionMarkCircleIcon,
certificate: CertificateIcon,
};
return icons[type] || DocumentIcon;
};
const getContentTypeLabel = (type) => {
const labels = {
document: 'Document',
image: 'Image',
video: 'Video',
article: 'Article',
quiz: 'Quiz',
certificate: 'Certificate',
};
return labels[type] || 'Document';
};
const renderContent = (content) => {
switch (content.type) {
case 'document':
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">{content.title}</h3>
{content.fileUrl && (
<a
href={content.fileUrl}
download
className="btn-secondary flex items-center"
>
<DownloadIcon className="h-4 w-4 mr-2" />
Download
</a>
)}
</div>
{content.description && (
<p className="text-gray-600">{content.description}</p>
)}
{content.fileUrl ? (
<div className="bg-gray-100 rounded-lg p-4">
<iframe
src={content.fileUrl}
className="w-full h-96"
title={content.title}
/>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<DocumentIcon className="h-16 w-16 mx-auto mb-4" />
<p>No document file available</p>
</div>
)}
</div>
);
case 'image':
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold">{content.title}</h3>
{content.description && (
<p className="text-gray-600">{content.description}</p>
)}
{content.fileUrl ? (
<div className="text-center">
<img
src={content.fileUrl}
alt={content.title}
className="max-w-full h-auto rounded-lg shadow-lg"
/>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<PhotoIcon className="h-16 w-16 mx-auto mb-4" />
<p>No image file available</p>
</div>
)}
</div>
);
case 'video':
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold">{content.title}</h3>
{content.description && (
<p className="text-gray-600">{content.description}</p>
)}
{content.fileUrl ? (
<div className="text-center">
<video
controls
className="max-w-full h-auto rounded-lg shadow-lg"
onTimeUpdate={(e) => {
const progress = (e.target.currentTime / e.target.duration) * 100;
handleVideoProgress(content.id, progress);
}}
>
<source src={content.fileUrl} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<VideoCameraIcon className="h-16 w-16 mx-auto mb-4" />
<p>No video file available</p>
</div>
)}
</div>
);
case 'article':
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold">{content.title}</h3>
{content.description && (
<p className="text-gray-600">{content.description}</p>
)}
<div className="prose max-w-none">
<div
className="text-gray-700 leading-relaxed"
dangerouslySetInnerHTML={{ __html: content.articleContent || 'No content available' }}
/>
</div>
</div>
);
case 'quiz':
return (
<div className="space-y-6">
<h3 className="text-xl font-semibold">{content.title}</h3>
{content.description && (
<p className="text-gray-600">{content.description}</p>
)}
{content.questions && content.questions.length > 0 ? (
<div className="space-y-6">
{content.questions.map((question, index) => (
<div key={question.id} className="border rounded-lg p-4 bg-gray-50">
<div className="flex items-start space-x-3 mb-4">
<span className="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-medium">
{index + 1}
</span>
<div className="flex-1">
<h4 className="font-medium text-gray-900 mb-2">{question.question}</h4>
<p className="text-sm text-gray-500">Points: {question.points}</p>
</div>
</div>
{question.questionType === 'multiple_choice' && (
<div className="space-y-2">
{question.options.map((option, optionIndex) => (
<label key={optionIndex} className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={quizAnswers[question.id]?.includes(option) || false}
onChange={(e) => {
const currentAnswers = quizAnswers[question.id] || [];
if (e.target.checked) {
handleQuizAnswer(question.id, [...currentAnswers, option]);
} else {
handleQuizAnswer(question.id, currentAnswers.filter(a => a !== option));
}
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-gray-700">{option}</span>
</label>
))}
</div>
)}
{question.questionType === 'single_choice' && (
<div className="space-y-2">
{question.options.map((option, optionIndex) => (
<label key={optionIndex} className="flex items-center space-x-3 cursor-pointer">
<input
type="radio"
name={`question-${question.id}`}
value={option}
checked={quizAnswers[question.id] === option}
onChange={(e) => handleQuizAnswer(question.id, e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="text-gray-700">{option}</span>
</label>
))}
</div>
)}
{question.questionType === 'true_false' && (
<div className="space-y-2">
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="radio"
name={`question-${question.id}`}
value="true"
checked={quizAnswers[question.id] === 'true'}
onChange={(e) => handleQuizAnswer(question.id, e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="text-gray-700">True</span>
</label>
<label className="flex items-center space-x-3 cursor-pointer">
<input
type="radio"
name={`question-${question.id}`}
value="false"
checked={quizAnswers[question.id] === 'false'}
onChange={(e) => handleQuizAnswer(question.id, e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="text-gray-700">False</span>
</label>
</div>
)}
{question.questionType === 'text' && (
<textarea
value={quizAnswers[question.id] || ''}
onChange={(e) => handleQuizAnswer(question.id, e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={3}
placeholder="Enter your answer..."
/>
)}
{quizResults[question.id] && (
<div className={`mt-3 p-3 rounded-lg ${
quizResults[question.id].correct
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
<div className="flex items-center space-x-2">
{quizResults[question.id].correct ? (
<CheckIcon className="h-4 w-4" />
) : (
<XMarkIcon className="h-4 w-4" />
)}
<span className="font-medium">
{quizResults[question.id].correct ? 'Correct!' : 'Incorrect'}
</span>
</div>
{question.explanation && (
<p className="mt-2 text-sm">{question.explanation}</p>
)}
</div>
)}
</div>
))}
<div className="flex justify-center pt-4">
<button
onClick={handleQuizSubmit}
disabled={submitQuizMutation.isLoading}
className="btn-primary"
>
{submitQuizMutation.isLoading ? 'Submitting...' : 'Submit Quiz'}
</button>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<QuestionMarkCircleIcon className="h-16 w-16 mx-auto mb-4" />
<p>No questions available for this quiz</p>
</div>
)}
</div>
);
case 'certificate':
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold">{content.title}</h3>
{content.description && (
<p className="text-gray-600">{content.description}</p>
)}
<div className="text-center py-8">
<CertificateIcon className="h-16 w-16 mx-auto mb-4 text-gray-400" />
<p className="text-gray-500">Certificate template will be displayed here</p>
</div>
</div>
);
default:
return (
<div className="text-center py-8 text-gray-500">
<p>Unsupported content type</p>
</div>
);
}
};
if (courseLoading || contentLoading) {
return <LoadingSpinner size="lg" className="mt-8" />;
}
if (!courseData?.course) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900">Course not found</h3>
<p className="text-gray-500">The course you're looking for doesn't exist.</p>
</div>
);
}
const course = courseData.course;
const contents = contentData?.contents || [];
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Link
to={`/courses/${id}`}
className="mr-4 p-2 text-gray-600 hover:text-gray-900"
>
<ArrowLeftIcon className="h-6 w-6" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
<p className="text-gray-600">Course Content</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Content Sidebar */}
<div className="lg:col-span-1">
<div className="card sticky top-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Course Materials</h2>
<div className="space-y-2">
{contents.length > 0 ? (
contents.map((content, index) => {
const IconComponent = getContentTypeIcon(content.type);
const isSelected = selectedContent?.id === content.id;
const isCompleted = videoProgress[content.id] >= 90; // 90% watched for videos
return (
<button
key={content.id}
onClick={() => setSelectedContent(content)}
className={`w-full text-left p-3 rounded-lg transition-colors ${
isSelected
? 'bg-blue-50 border-blue-200 border'
: 'hover:bg-gray-50'
}`}
>
<div className="flex items-center space-x-3">
<IconComponent className="h-5 w-5 text-gray-600" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{content.title}
</p>
<p className="text-xs text-gray-500">
{getContentTypeLabel(content.type)}
</p>
</div>
{isCompleted && (
<CheckIcon className="h-4 w-4 text-green-600" />
)}
</div>
</button>
);
})
) : (
<div className="text-center py-8 text-gray-500">
<p>No content available</p>
</div>
)}
</div>
</div>
</div>
{/* Content Viewer */}
<div className="lg:col-span-3">
<div className="card">
{selectedContent ? (
renderContent(selectedContent)
) : (
<div className="text-center py-12 text-gray-500">
<EyeIcon className="h-16 w-16 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Select Content</h3>
<p className="text-sm">Choose a content item from the sidebar to view</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default CourseContentViewer;

View file

@ -10,14 +10,17 @@ import {
StarIcon,
BookOpenIcon,
CogIcon,
EyeIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import TrainerAssignmentModal from '../components/TrainerAssignmentModal';
import toast from 'react-hot-toast';
const CourseDetail = () => {
const { id } = useParams();
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
const [enrolling, setEnrolling] = useState(false);
const [showTrainerModal, setShowTrainerModal] = useState(false);
const queryClient = useQueryClient();
const { data: course, isLoading, error } = useQuery(
@ -114,15 +117,34 @@ const CourseDetail = () => {
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
</div>
<div className="flex space-x-3">
<Link
to={`/courses/${id}/learn`}
className="btn-primary flex items-center shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<EyeIcon className="h-5 w-5 mr-2" />
View Content
</Link>
{(isTrainer || isSuperAdmin) && (
<Link
to={`/courses/${id}/content`}
className="btn-primary flex items-center shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
className="btn-secondary flex items-center"
>
<CogIcon className="h-5 w-5 mr-2" />
Manage Content
</Link>
)}
{isSuperAdmin && (
<button
onClick={() => setShowTrainerModal(true)}
className="btn-secondary flex items-center"
title="Assign Trainer (Super Admin only)"
>
<UserIcon className="h-4 w-4 mr-2" />
Assign Trainer
</button>
)}
</div>
</div>
</div>
@ -308,6 +330,14 @@ const CourseDetail = () => {
</div>
</div>
</div>
{/* Trainer Assignment Modal */}
<TrainerAssignmentModal
isOpen={showTrainerModal}
onClose={() => setShowTrainerModal(false)}
courseId={id}
currentTrainer={courseData?.trainer}
/>
</div>
);
};

View file

@ -10,8 +10,10 @@ import {
BookOpenIcon,
TagIcon,
UserGroupIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import TrainerAssignmentModal from '../components/TrainerAssignmentModal';
import toast from 'react-hot-toast';
const CourseEdit = () => {
@ -27,6 +29,9 @@ const CourseEdit = () => {
const [imagePreview, setImagePreview] = useState(null);
const [uploadingImage, setUploadingImage] = useState(false);
// Trainer assignment modal state
const [showTrainerModal, setShowTrainerModal] = useState(false);
const handleImageChange = (e) => {
@ -433,7 +438,36 @@ const CourseEdit = () => {
</button>
</div>
{/* Trainer Assignment - Super Admin Only */}
{isSuperAdmin && courseData?.course && (
<div className="card mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Trainer Assignment</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-2">Current Trainer:</p>
{courseData.course.trainer ? (
<div className="flex items-center">
<UserIcon className="h-5 w-5 text-gray-400 mr-2" />
<span className="font-medium">
{courseData.course.trainer.firstName} {courseData.course.trainer.lastName}
</span>
<span className="text-gray-500 ml-2">({courseData.course.trainer.email})</span>
</div>
) : (
<p className="text-sm text-gray-500">No trainer assigned</p>
)}
</div>
<button
type="button"
onClick={() => setShowTrainerModal(true)}
className="btn-secondary flex items-center"
>
<UserIcon className="h-4 w-4 mr-2" />
Assign Trainer
</button>
</div>
</div>
)}
{/* Submit Buttons */}
<div className="flex justify-end space-x-4">
@ -464,7 +498,13 @@ const CourseEdit = () => {
</div>
</form>
{/* Trainer Assignment Modal */}
<TrainerAssignmentModal
isOpen={showTrainerModal}
onClose={() => setShowTrainerModal(false)}
courseId={id}
currentTrainer={courseData?.course?.trainer}
/>
</div>
);
};

View file

@ -179,8 +179,8 @@ const Courses = () => {
{/* Course Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{coursesData?.courses?.map((course) => (
<Link key={course.id} to={`/courses/${course.id}`} className="block">
<div className="card mb-4 cursor-pointer hover:shadow-lg transition-shadow">
<div key={course.id} className="card mb-4 cursor-pointer hover:shadow-lg transition-shadow">
<Link to={`/courses/${course.id}`} className="block">
<div className="aspect-w-16 aspect-h-9 mb-4">
{course.thumbnail ? (
<img
@ -228,6 +228,8 @@ const Courses = () => {
</span>
</div>
)}
</div>
</Link>
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
<div className="text-lg font-bold text-gray-900">
@ -236,13 +238,12 @@ const Courses = () => {
<Link
to={isTrainer || isSuperAdmin ? `/courses/${course.id}/edit` : `/courses/${course.id}`}
className="btn-primary text-sm"
onClick={(e) => e.stopPropagation()}
>
{isTrainer || isSuperAdmin ? 'Edit' : 'View Details'}
</Link>
</div>
</div>
</div>
</Link>
))}
</div>

View file

@ -9,19 +9,25 @@ const Login = () => {
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(''); // Clear previous errors
try {
const result = await login(email, password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || 'Login failed');
console.error('Login failed:', result.error);
}
} catch (error) {
setError('An unexpected error occurred');
console.error('Login error:', error);
} finally {
setLoading(false);
@ -101,6 +107,21 @@ const Login = () => {
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Login Error
</h3>
<div className="mt-2 text-sm text-red-700">
{error}
</div>
</div>
</div>
</div>
)}
<div>
<button
type="submit"

View file

@ -75,6 +75,8 @@ export const coursesAPI = {
update: (id, data) => api.put(`/courses/${id}`, data),
delete: (id) => api.delete(`/courses/${id}`),
publish: (id, isPublished) => api.put(`/courses/${id}/publish`, { isPublished }),
assignTrainer: (id, trainerId) => api.put(`/courses/${id}/assign-trainer`, { trainerId }),
getAvailableTrainers: () => api.get('/courses/trainers/available'),
getCategories: () => api.get('/courses/categories/all'),
getStats: () => api.get('/courses/stats/overview'),
uploadCourseImage: (courseName, file) => {
@ -88,8 +90,8 @@ export const coursesAPI = {
// Course Content API
export const courseContentAPI = {
getAll: (courseId, params) => api.get(`/course-content/${courseId}/content`, { params }),
getById: (courseId, contentId) => api.get(`/course-content/${courseId}/content/${contentId}`),
getAll: (courseId, params) => api.get(`/course-content/${courseId}/content`, { params }).then(res => res.data),
getById: (courseId, contentId) => api.get(`/course-content/${courseId}/content/${contentId}`).then(res => res.data),
create: (courseId, data) => api.post(`/course-content/${courseId}/content`, data),
update: (courseId, contentId, data) => api.put(`/course-content/${courseId}/content/${contentId}`, data),
delete: (courseId, contentId) => api.delete(`/course-content/${courseId}/content/${contentId}`),
@ -103,6 +105,8 @@ export const courseContentAPI = {
},
addQuizQuestions: (courseId, contentId, questions) =>
api.post(`/course-content/${courseId}/content/${contentId}/questions`, { questions }),
submitQuiz: (courseId, contentId, data) =>
api.post(`/course-content/${courseId}/content/${contentId}/submit`, data),
};
// Enrollments API

View file

@ -1,4 +1,4 @@
CourseWorx v1.1.1 - Enhanced Course Content Management Interface
CourseWorx v1.1.2 - Trainer Assignment System & Navigation Improvements
==========================================================
This version adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System.
@ -111,6 +111,45 @@ MAJOR FEATURES IMPLEMENTED:
- Assignment management
- Database migrations and seeding
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:
========================
@ -347,5 +386,5 @@ DEPLOYMENT READINESS:
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.1
Version: 1.1.2
Status: Production Ready