diff --git a/backend/routes/courses.js b/backend/routes/courses.js index 8d456a3..7bc2463 100644 --- a/backend/routes/courses.js +++ b/backend/routes/courses.js @@ -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 diff --git a/frontend/src/App.js b/frontend/src/App.js index cbb81a8..4575477 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 = () => { } /> + + + + } /> diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index 3440afc..3e10f4b 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -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 = () => { ))} -
-
-
-
- - {user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)} - -
-
-
-

- {user?.firstName} {user?.lastName} -

-

{user?.role?.replace('_', ' ')}

-
-
- -
@@ -120,30 +111,6 @@ const Layout = () => { ))} -
-
-
-
- - {user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)} - -
-
-
-

- {user?.firstName} {user?.lastName} -

-

{user?.role?.replace('_', ' ')}

-
-
- -
@@ -161,10 +128,54 @@ const Layout = () => {
-
- - Welcome back, {user?.firstName}! - + + {/* User dropdown menu */} +
+ + + {/* Dropdown menu */} + {userMenuOpen && ( +
+
+ setUserMenuOpen(false)} + > + + Profile + + +
+
+ )}
diff --git a/frontend/src/components/TrainerAssignmentModal.js b/frontend/src/components/TrainerAssignmentModal.js new file mode 100644 index 0000000..ef56a32 --- /dev/null +++ b/frontend/src/components/TrainerAssignmentModal.js @@ -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 ( +
+
+
+

Access Denied

+

+ Only Super Admins can assign trainers to courses. +

+ +
+
+
+ ); + } + + return ( +
+
+
+
+
+ +

+ Assign Trainer +

+
+ +
+ + {currentTrainer && ( +
+

+ Current Trainer: {currentTrainer.firstName} {currentTrainer.lastName} +

+
+ )} + +
+ + {trainersLoading ? ( +
+ +
+ ) : ( + + )} +
+ + {trainersError && ( +
+

+ Error loading trainers: {trainersError.message} +

+
+ )} + + {trainersData?.trainers?.length === 0 && !trainersLoading && !trainersError && ( +
+

+ No active trainers available. Please create trainer accounts first. +

+
+ )} + + {/* Debug info - remove in production */} + {process.env.NODE_ENV === 'development' && ( +
+

Debug: trainersData = {JSON.stringify(trainersData, null, 2)}

+

Debug: trainersLoading = {trainersLoading}

+

Debug: trainersError = {trainersError?.message}

+

Debug: Current user role = {user?.role}

+

Debug: Current user ID = {user?.id}

+
+ )} + +
+ + +
+
+
+
+ ); +}; + +export default TrainerAssignmentModal; \ No newline at end of file diff --git a/frontend/src/pages/CourseContent.js b/frontend/src/pages/CourseContent.js index eaf0720..e9f929c 100644 --- a/frontend/src/pages/CourseContent.js +++ b/frontend/src/pages/CourseContent.js @@ -1,22 +1,12 @@ import React, { useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from 'react-query'; import { useAuth } from '../contexts/AuthContext'; -import { coursesAPI, courseContentAPI } from '../services/api'; +import { courseContentAPI } from '../services/api'; import { - PlusIcon, - DocumentIcon, - PhotoIcon, - VideoCameraIcon, - DocumentTextIcon, - QuestionMarkCircleIcon, - AcademicCapIcon as CertificateIcon, - TrashIcon, - PencilIcon, - EyeIcon, - EyeSlashIcon, - ArrowLeftIcon, - AcademicCapIcon, + PlusIcon, DocumentIcon, PhotoIcon, VideoCameraIcon, DocumentTextIcon, + QuestionMarkCircleIcon, AcademicCapIcon, TrashIcon, PencilIcon, + ArrowLeftIcon, XMarkIcon, PlusIcon as AddIcon } from '@heroicons/react/24/outline'; import LoadingSpinner from '../components/LoadingSpinner'; import toast from 'react-hot-toast'; @@ -27,46 +17,51 @@ const CourseContent = () => { const { isTrainer, isSuperAdmin } = useAuth(); const queryClient = useQueryClient(); - // Content management state + // State for modals and forms const [showAddContentModal, setShowAddContentModal] = useState(false); const [showEditContentModal, setShowEditContentModal] = useState(false); + const [showQuizQuestionsModal, setShowQuizQuestionsModal] = useState(false); const [editingContent, setEditingContent] = useState(null); const [contentForm, setContentForm] = useState({ - title: '', - description: '', - type: 'document', - order: 0, - points: 0, - isRequired: true, - isPublished: true, - articleContent: '', + title: '', description: '', type: 'document', order: 0, points: 0, + isRequired: true, isPublished: true, articleContent: '', }); const [uploadingFile, setUploadingFile] = useState(false); const [selectedFile, setSelectedFile] = useState(null); - // Get course details - const { data: courseData, isLoading: courseLoading } = useQuery( - ['course', id], - () => coursesAPI.getById(id), - { enabled: !!id } - ); + // Quiz questions state + const [quizQuestions, setQuizQuestions] = useState([]); + const [currentQuestion, setCurrentQuestion] = useState({ + question: '', + questionType: 'single_choice', + options: ['', '', '', ''], + correctAnswer: '', + points: 1, + explanation: '', + order: 0 + }); // Get course content - const { data: contentData, isLoading: contentLoading } = useQuery( + const { data: contentData, isLoading: contentLoading, error: contentError } = useQuery( ['course-content', id], () => courseContentAPI.getAll(id), - { enabled: !!id } + { + enabled: !!id, + onError: (error) => { + console.error('Error fetching content:', error); + } + } ); - // Content management mutations + // Mutations for CRUD operations const createContentMutation = useMutation( (data) => courseContentAPI.create(id, data), { onSuccess: () => { queryClient.invalidateQueries(['course-content', id]); - toast.success('Content added successfully!'); setShowAddContentModal(false); resetContentForm(); + toast.success('Content added successfully!'); }, onError: (error) => { toast.error(error.response?.data?.error || 'Failed to add content'); @@ -75,14 +70,14 @@ const CourseContent = () => { ); const updateContentMutation = useMutation( - (data) => courseContentAPI.update(id, editingContent.id, data), + ({ contentId, data }) => courseContentAPI.update(id, contentId, data), { onSuccess: () => { queryClient.invalidateQueries(['course-content', id]); - toast.success('Content updated successfully!'); setShowEditContentModal(false); setEditingContent(null); resetContentForm(); + toast.success('Content updated successfully!'); }, onError: (error) => { toast.error(error.response?.data?.error || 'Failed to update content'); @@ -104,21 +99,51 @@ const CourseContent = () => { ); const uploadFileMutation = useMutation( - ({ contentType, file, contentId }) => courseContentAPI.uploadFile(id, contentType, file, contentId), + ({ file, contentType }) => courseContentAPI.uploadFile(id, file, contentType), { onSuccess: (data) => { - toast.success('File uploaded successfully!'); - setSelectedFile(null); + setContentForm(prev => ({ + ...prev, + fileUrl: data.fileUrl, + fileSize: data.fileSize, + fileType: data.fileType + })); setUploadingFile(false); + setSelectedFile(null); + toast.success('File uploaded successfully!'); }, onError: (error) => { - toast.error(error.response?.data?.error || 'Failed to upload file'); setUploadingFile(false); + toast.error(error.response?.data?.error || 'Failed to upload file'); }, } ); - // Content management handlers + const addQuizQuestionsMutation = useMutation( + ({ contentId, questions }) => courseContentAPI.addQuizQuestions(id, contentId, questions), + { + onSuccess: () => { + queryClient.invalidateQueries(['course-content', id]); + setShowQuizQuestionsModal(false); + setQuizQuestions([]); + setCurrentQuestion({ + question: '', + questionType: 'single_choice', + options: ['', '', '', ''], + correctAnswer: '', + points: 1, + explanation: '', + order: 0 + }); + toast.success('Quiz questions added successfully!'); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to add quiz questions'); + }, + } + ); + + // Handlers for form changes, add/edit/delete, file upload const handleContentFormChange = (e) => { const { name, value, type, checked } = e.target; setContentForm(prev => ({ @@ -129,15 +154,10 @@ const CourseContent = () => { const resetContentForm = () => { setContentForm({ - title: '', - description: '', - type: 'document', - order: 0, - points: 0, - isRequired: true, - isPublished: true, - articleContent: '', + title: '', description: '', type: 'document', order: 0, points: 0, + isRequired: true, isPublished: true, articleContent: '', }); + setSelectedFile(null); }; const handleAddContent = async (e) => { @@ -147,7 +167,10 @@ const CourseContent = () => { const handleEditContent = async (e) => { e.preventDefault(); - updateContentMutation.mutate(contentForm); + updateContentMutation.mutate({ + contentId: editingContent.id, + data: contentForm + }); }; const handleDeleteContent = (contentId) => { @@ -159,13 +182,13 @@ const CourseContent = () => { const handleEditContentClick = (content) => { setEditingContent(content); setContentForm({ - title: content.title || '', - description: content.description || '', - type: content.type || 'document', - order: content.order || 0, - points: content.points || 0, - isRequired: content.isRequired !== undefined ? content.isRequired : true, - isPublished: content.isPublished !== undefined ? content.isPublished : true, + title: content.title, + description: content.description, + type: content.type, + order: content.order, + points: content.points, + isRequired: content.isRequired, + isPublished: content.isPublished, articleContent: content.articleContent || '', }); setShowEditContentModal(true); @@ -174,16 +197,14 @@ const CourseContent = () => { const handleFileUpload = async () => { if (!selectedFile) return; setUploadingFile(true); - uploadFileMutation.mutate({ - contentType: contentForm.type, - file: selectedFile, - contentId: editingContent?.id || null - }); + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('contentType', contentForm.type); + uploadFileMutation.mutate({ file: formData, contentType: contentForm.type }); }; const handleFileChange = (e) => { - const file = e.target.files[0]; - setSelectedFile(file); + setSelectedFile(e.target.files[0]); }; const getContentTypeIcon = (type) => { @@ -193,7 +214,7 @@ const CourseContent = () => { video: VideoCameraIcon, article: DocumentTextIcon, quiz: QuestionMarkCircleIcon, - certificate: CertificateIcon, + certificate: AcademicCapIcon, }; return icons[type] || DocumentIcon; }; @@ -210,6 +231,80 @@ const CourseContent = () => { return labels[type] || 'Document'; }; + // Quiz question handlers + const handleQuestionFormChange = (e) => { + const { name, value } = e.target; + setCurrentQuestion(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleOptionChange = (index, value) => { + const newOptions = [...currentQuestion.options]; + newOptions[index] = value; + setCurrentQuestion(prev => ({ + ...prev, + options: newOptions + })); + }; + + const addQuestion = () => { + if (!currentQuestion.question.trim()) { + toast.error('Please enter a question'); + return; + } + + if (currentQuestion.questionType !== 'text' && currentQuestion.options.filter(opt => opt.trim()).length < 2) { + toast.error('Please add at least 2 options'); + return; + } + + if (!currentQuestion.correctAnswer.trim()) { + toast.error('Please specify the correct answer'); + return; + } + + const newQuestion = { + ...currentQuestion, + id: Date.now(), // Temporary ID for frontend + order: quizQuestions.length + }; + + setQuizQuestions(prev => [...prev, newQuestion]); + setCurrentQuestion({ + question: '', + questionType: 'single_choice', + options: ['', '', '', ''], + correctAnswer: '', + points: 1, + explanation: '', + order: 0 + }); + }; + + const removeQuestion = (index) => { + setQuizQuestions(prev => prev.filter((_, i) => i !== index)); + }; + + const handleAddQuizQuestions = (contentId) => { + if (quizQuestions.length === 0) { + toast.error('Please add at least one question'); + return; + } + + addQuizQuestionsMutation.mutate({ + contentId, + questions: quizQuestions + }); + }; + + const openQuizQuestionsModal = (content) => { + setEditingContent(content); + setQuizQuestions(content.questions || []); + setShowQuizQuestionsModal(true); + }; + if (!isTrainer && !isSuperAdmin) { return (
@@ -219,15 +314,23 @@ const CourseContent = () => { ); } - if (courseLoading || contentLoading) { + if (contentLoading) { return ; } + if (contentError) { + return ( +
+

Error loading content: {contentError.message}

+
+ ); + } + return ( -
+
{/* Header */}
-
+

Course Content

-

- {courseData?.course?.title} - Manage course materials -

+

Manage course materials

+ + {/* Content List */}
-
-

Course Materials

-
- {contentData?.contents?.length || 0} items -
-
+

+ Course Materials + {contentData?.contents && ( + + ({contentData.contents.length} items) + + )} +

- {contentData?.contents?.length > 0 ? ( + {contentData?.contents && contentData.contents.length > 0 ? (
{contentData.contents.map((content) => { const IconComponent = getContentTypeIcon(content.type); return ( -
+
-
-

{content.title}

-

{getContentTypeLabel(content.type)}

- {content.description && ( -

{content.description}

- )} - {content.fileUrl && ( -

File attached

- )} +
+

{content.title}

+

{getContentTypeLabel(content.type)}

+
+ Order: {content.order} + Points: {content.points} + {content.isRequired && ( + Required + )} + {content.isPublished ? ( + Published + ) : ( + Draft + )} +
-
-
- - Order: {content.order} - - - Points: {content.points} - - {content.isRequired && ( - - Required - - )} - {content.isPublished ? ( - - ) : ( - - )} -
-
+
+ {content.type === 'quiz' && ( - -
+ )} + +
@@ -325,10 +423,8 @@ const CourseContent = () => { })}
) : ( -
- -

No content yet

-

Start building your course by adding content

+
+

No content available

)}
@@ -354,7 +450,26 @@ const CourseContent = () => {
+ +
+ +
+ { onChange={handleContentFormChange} className="input-field w-full" rows={3} - placeholder="Brief description of this content" + placeholder="Enter content description" />
-
- - -
-
@@ -427,36 +521,30 @@ const CourseContent = () => { onChange={handleContentFormChange} className="input-field w-full" min="0" - placeholder="0" />
-
+
- -
+ Required + +
+ Published +
@@ -477,6 +565,22 @@ const CourseContent = () => {
)} + {/* Quiz Questions */} + {contentForm.type === 'quiz' && ( +
+ +
+
+

+ Quiz questions can be added after creating the quiz content. +

+
+
+
+ )} + {/* File Upload for Document, Image, Video */} {['document', 'image', 'video'].includes(contentForm.type) && (
@@ -557,7 +661,27 @@ const CourseContent = () => {
+ +
+ +
+ { onChange={handleContentFormChange} className="input-field w-full" rows={3} - placeholder="Brief description of this content" + placeholder="Enter content description" />
-
- - -
-
@@ -630,36 +733,30 @@ const CourseContent = () => { onChange={handleContentFormChange} className="input-field w-full" min="0" - placeholder="0" />
-
+
- -
+ Required + +
+ Published +
@@ -680,40 +777,6 @@ const CourseContent = () => {
)} - {/* File Upload for Document, Image, Video */} - {['document', 'image', 'video'].includes(contentForm.type) && ( -
- - - {selectedFile && ( -
-

Selected: {selectedFile.name}

- -
- )} -
- )} -
)} + + {/* Quiz Questions Modal */} + {showQuizQuestionsModal && editingContent && ( +
+
+
+

+ Manage Quiz Questions: {editingContent.title} +

+ +
+ +
+ {/* Add Question Form */} +
+

Add New Question

+ +
+ +