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:
parent
cee2aaf713
commit
6cc071c8d4
12 changed files with 1498 additions and 313 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,10 +128,54 @@ 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}!
|
||||
</span>
|
||||
|
||||
{/* 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>
|
||||
|
|
|
|||
188
frontend/src/components/TrainerAssignmentModal.js
Normal file
188
frontend/src/components/TrainerAssignmentModal.js
Normal 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
480
frontend/src/pages/CourseContentViewer.js
Normal file
480
frontend/src/pages/CourseContentViewer.js
Normal 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;
|
||||
|
|
@ -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>
|
||||
{(isTrainer || isSuperAdmin) && (
|
||||
<div className="flex space-x-3">
|
||||
<Link
|
||||
to={`/courses/${id}/content`}
|
||||
to={`/courses/${id}/learn`}
|
||||
className="btn-primary flex items-center shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<CogIcon className="h-5 w-5 mr-2" />
|
||||
Manage Content
|
||||
<EyeIcon className="h-5 w-5 mr-2" />
|
||||
View Content
|
||||
</Link>
|
||||
)}
|
||||
{(isTrainer || isSuperAdmin) && (
|
||||
<Link
|
||||
to={`/courses/${id}/content`}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,21 +228,22 @@ const Courses = () => {
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatPrice(course.price)}
|
||||
</div>
|
||||
<Link
|
||||
to={isTrainer || isSuperAdmin ? `/courses/${course.id}/edit` : `/courses/${course.id}`}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{isTrainer || isSuperAdmin ? 'Edit' : 'View Details'}
|
||||
</Link>
|
||||
</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">
|
||||
{formatPrice(course.price)}
|
||||
</div>
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
version.txt
43
version.txt
|
|
@ -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
|
||||
Loading…
Reference in a new issue