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
|
// @route GET /api/courses/stats/overview
|
||||||
// @desc Get course statistics (Super Admin or Trainer)
|
// @desc Get course statistics (Super Admin or Trainer)
|
||||||
// @access Private
|
// @access Private
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import Layout from './components/Layout';
|
||||||
import LoadingSpinner from './components/LoadingSpinner';
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
import CourseEdit from './pages/CourseEdit';
|
import CourseEdit from './pages/CourseEdit';
|
||||||
import CourseContent from './pages/CourseContent';
|
import CourseContent from './pages/CourseContent';
|
||||||
|
import CourseContentViewer from './pages/CourseContentViewer';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
|
||||||
const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
||||||
|
|
@ -64,6 +65,11 @@ const AppRoutes = () => {
|
||||||
<CourseContent />
|
<CourseContent />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/courses/:id/learn" element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<CourseContentViewer />
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/users/import" element={
|
<Route path="/users/import" element={
|
||||||
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
||||||
<UserImport />
|
<UserImport />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ArrowRightOnRectangleIcon,
|
ArrowRightOnRectangleIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
|
|
@ -18,6 +19,7 @@ const Layout = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Check if user requires password change
|
// Check if user requires password change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -26,11 +28,24 @@ const Layout = () => {
|
||||||
}
|
}
|
||||||
}, [user?.requiresPasswordChange]);
|
}, [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 = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||||
{ name: 'Courses', href: '/courses', icon: AcademicCapIcon },
|
{ name: 'Courses', href: '/courses', icon: AcademicCapIcon },
|
||||||
...(isSuperAdmin ? [{ name: 'Users', href: '/users', icon: UsersIcon }] : []),
|
...(isSuperAdmin ? [{ name: 'Users', href: '/users', icon: UsersIcon }] : []),
|
||||||
{ name: 'Profile', href: '/profile', icon: UserIcon },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|
@ -71,30 +86,6 @@ const Layout = () => {
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -120,30 +111,6 @@ const Layout = () => {
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -161,10 +128,54 @@ const Layout = () => {
|
||||||
<div className="flex flex-1"></div>
|
<div className="flex flex-1"></div>
|
||||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
<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="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">
|
{/* User dropdown menu */}
|
||||||
Welcome back, {user?.firstName}!
|
<div className="relative user-menu">
|
||||||
</span>
|
<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>
|
||||||
</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,
|
StarIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
|
EyeIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import TrainerAssignmentModal from '../components/TrainerAssignmentModal';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const CourseDetail = () => {
|
const CourseDetail = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
|
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
|
||||||
const [enrolling, setEnrolling] = useState(false);
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
|
const [showTrainerModal, setShowTrainerModal] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: course, isLoading, error } = useQuery(
|
const { data: course, isLoading, error } = useQuery(
|
||||||
|
|
@ -114,15 +117,34 @@ const CourseDetail = () => {
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
|
||||||
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
|
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
{(isTrainer || isSuperAdmin) && (
|
<div className="flex space-x-3">
|
||||||
<Link
|
<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"
|
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" />
|
<EyeIcon className="h-5 w-5 mr-2" />
|
||||||
Manage Content
|
View Content
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -308,6 +330,14 @@ const CourseDetail = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trainer Assignment Modal */}
|
||||||
|
<TrainerAssignmentModal
|
||||||
|
isOpen={showTrainerModal}
|
||||||
|
onClose={() => setShowTrainerModal(false)}
|
||||||
|
courseId={id}
|
||||||
|
currentTrainer={courseData?.trainer}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
|
UserIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import TrainerAssignmentModal from '../components/TrainerAssignmentModal';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const CourseEdit = () => {
|
const CourseEdit = () => {
|
||||||
|
|
@ -27,6 +29,9 @@ const CourseEdit = () => {
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
const [uploadingImage, setUploadingImage] = useState(false);
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
|
||||||
|
// Trainer assignment modal state
|
||||||
|
const [showTrainerModal, setShowTrainerModal] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleImageChange = (e) => {
|
const handleImageChange = (e) => {
|
||||||
|
|
@ -433,7 +438,36 @@ const CourseEdit = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Submit Buttons */}
|
||||||
<div className="flex justify-end space-x-4">
|
<div className="flex justify-end space-x-4">
|
||||||
|
|
@ -464,7 +498,13 @@ const CourseEdit = () => {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Trainer Assignment Modal */}
|
||||||
|
<TrainerAssignmentModal
|
||||||
|
isOpen={showTrainerModal}
|
||||||
|
onClose={() => setShowTrainerModal(false)}
|
||||||
|
courseId={id}
|
||||||
|
currentTrainer={courseData?.course?.trainer}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,8 @@ const Courses = () => {
|
||||||
{/* Course Grid */}
|
{/* Course Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{coursesData?.courses?.map((course) => (
|
{coursesData?.courses?.map((course) => (
|
||||||
<Link key={course.id} to={`/courses/${course.id}`} className="block">
|
<div key={course.id} className="card mb-4 cursor-pointer hover:shadow-lg transition-shadow">
|
||||||
<div 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">
|
<div className="aspect-w-16 aspect-h-9 mb-4">
|
||||||
{course.thumbnail ? (
|
{course.thumbnail ? (
|
||||||
<img
|
<img
|
||||||
|
|
@ -228,21 +228,22 @@ const Courses = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,25 @@ const Login = () => {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(''); // Clear previous errors
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await login(email, password);
|
const result = await login(email, password);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Login failed');
|
||||||
|
console.error('Login failed:', result.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setError('An unexpected error occurred');
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -101,6 +107,21 @@ const Login = () => {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export const coursesAPI = {
|
||||||
update: (id, data) => api.put(`/courses/${id}`, data),
|
update: (id, data) => api.put(`/courses/${id}`, data),
|
||||||
delete: (id) => api.delete(`/courses/${id}`),
|
delete: (id) => api.delete(`/courses/${id}`),
|
||||||
publish: (id, isPublished) => api.put(`/courses/${id}/publish`, { isPublished }),
|
publish: (id, isPublished) => api.put(`/courses/${id}/publish`, { isPublished }),
|
||||||
|
assignTrainer: (id, trainerId) => api.put(`/courses/${id}/assign-trainer`, { trainerId }),
|
||||||
|
getAvailableTrainers: () => api.get('/courses/trainers/available'),
|
||||||
getCategories: () => api.get('/courses/categories/all'),
|
getCategories: () => api.get('/courses/categories/all'),
|
||||||
getStats: () => api.get('/courses/stats/overview'),
|
getStats: () => api.get('/courses/stats/overview'),
|
||||||
uploadCourseImage: (courseName, file) => {
|
uploadCourseImage: (courseName, file) => {
|
||||||
|
|
@ -88,8 +90,8 @@ export const coursesAPI = {
|
||||||
|
|
||||||
// Course Content API
|
// Course Content API
|
||||||
export const courseContentAPI = {
|
export const courseContentAPI = {
|
||||||
getAll: (courseId, params) => api.get(`/course-content/${courseId}/content`, { params }),
|
getAll: (courseId, params) => api.get(`/course-content/${courseId}/content`, { params }).then(res => res.data),
|
||||||
getById: (courseId, contentId) => api.get(`/course-content/${courseId}/content/${contentId}`),
|
getById: (courseId, contentId) => api.get(`/course-content/${courseId}/content/${contentId}`).then(res => res.data),
|
||||||
create: (courseId, data) => api.post(`/course-content/${courseId}/content`, data),
|
create: (courseId, data) => api.post(`/course-content/${courseId}/content`, data),
|
||||||
update: (courseId, contentId, data) => api.put(`/course-content/${courseId}/content/${contentId}`, data),
|
update: (courseId, contentId, data) => api.put(`/course-content/${courseId}/content/${contentId}`, data),
|
||||||
delete: (courseId, contentId) => api.delete(`/course-content/${courseId}/content/${contentId}`),
|
delete: (courseId, contentId) => api.delete(`/course-content/${courseId}/content/${contentId}`),
|
||||||
|
|
@ -103,6 +105,8 @@ export const courseContentAPI = {
|
||||||
},
|
},
|
||||||
addQuizQuestions: (courseId, contentId, questions) =>
|
addQuizQuestions: (courseId, contentId, questions) =>
|
||||||
api.post(`/course-content/${courseId}/content/${contentId}/questions`, { 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
|
// 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.
|
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
|
- Assignment management
|
||||||
- Database migrations and seeding
|
- 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:
|
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.
|
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]
|
Release Date: [Current Date]
|
||||||
Version: 1.1.1
|
Version: 1.1.2
|
||||||
Status: Production Ready
|
Status: Production Ready
|
||||||
Loading…
Reference in a new issue