Commit b0d0c7a0 authored by AravindR-K's avatar AravindR-K

feat : Hr page updated with all functionality

parent be53edba
const express = require('express'); const express = require('express');
const xlsx = require('xlsx'); const xlsx = require('xlsx');
const fs = require('fs'); const fs = require('fs');
const User = require('../models/User'); const User = require('../models/User');
const Group = require('../models/Group'); const Group = require('../models/Group');
const Quiz = require('../models/Quiz'); const Quiz = require('../models/Quiz');
const Question = require('../models/Question'); const Question = require('../models/Question');
const Submission = require('../models/Submission'); const Submission = require('../models/Submission');
const { protect, authorize } = require('../middleware/auth'); const { protect, authorize } = require('../middleware/auth');
const upload = require('../middleware/upload'); const upload = require('../middleware/upload');
const router = express.Router(); const router = express.Router();
// All HR routes require authentication + hr role // All admin routes require authentication + admin role
router.use(protect, authorize('hr')); router.use(protect, authorize('hr'));
// ============ QUIZ MANAGEMENT ============ // ============ USER MANAGEMENT ============
// @route POST /api/hr/quiz/create // @route GET /api/hr/users
// @desc Create quiz with Excel upload // @desc Get all candidates and HR users
// @access HR // @access hr
router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => { router.get('/users', async (req, res) => {
try {
const { role } = req.query;
const filter = role ? { role } : { role: { $in: ['candidate', 'hr'] } };
const users = await User.find(filter)
.select('-password')
.sort({ createdAt: -1 });
res.json({ users });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/users/logged-in
// @desc Get all currently logged-in users
// @access hr
router.get('/users/logged-in', async (req, res) => {
try {
const users = await User.find({ role: { $in: ['candidate', 'hr'] }, isLoggedIn: true })
.select('-password')
.sort({ createdAt: -1 });
res.json({ users });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route DELETE /api/hr/users/:userId
// @desc Delete a user
// @access hr
router.delete('/users/:userId', async (req, res) => {
try {
const { userId } = req.params;
const user = await User.findById(userId);
if (!user) return res.status(404).json({ message: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ message: 'Cannot delete admin users' });
await Submission.deleteMany({ studentId: userId });
await User.findByIdAndDelete(userId);
res.json({ message: 'User deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/hr/users/:userId/level
// @desc Update a candidate's level (beginner/intermediate/advanced/expert)
// @access hr
router.put('/users/:userId/level', async (req, res) => {
try {
const { userId } = req.params;
const { level } = req.body;
const validLevels = ['beginner', 'intermediate', 'advanced', 'expert'];
if (!level || !validLevels.includes(level)) {
return res.status(400).json({ message: 'Invalid level. Must be one of: beginner, intermediate, advanced, expert' });
}
const user = await User.findById(userId);
if (!user) return res.status(404).json({ message: 'User not found' });
if (user.role !== 'candidate') return res.status(400).json({ message: 'Level can only be set for candidates' });
const previousLevel = user.level || 'beginner';
user.level = level;
await user.save();
res.json({
message: `Level changed from ${previousLevel} to ${level}`,
user: { _id: user._id, name: user.name, level: user.level },
previousLevel,
newLevel: level
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/hr/users/:userId
// @desc Edit a user (specifically for HR users to edit email/password)
// @access hr
router.put('/users/:userId', async (req, res) => {
try {
const { userId } = req.params;
const { email, password, name, group } = req.body;
const user = await User.findById(userId);
if (!user) return res.status(404).json({ message: 'User not found' });
// Ensure we don't accidentally edit an admin user here for safety
if (user.role === 'admin') {
return res.status(403).json({ message: 'Cannot edit admin users through this endpoint' });
}
if (email) user.email = email;
if (password) user.password = password; // Will be hashed by pre-save hook
if (name) user.name = name;
if (group !== undefined) user.group = group;
await user.save();
res.json({ message: 'User updated successfully', user: { id: user._id, name: user.name, email: user.email, role: user.role } });
} catch (error) {
if (error.code === 11000) {
return res.status(400).json({ message: 'Email already exists' });
}
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/hr/users/:userId/group
// @desc Assign user to a group explicitly
// @access hr
router.put('/users/:userId/group', async (req, res) => {
try {
const { userId } = req.params;
const { group } = req.body;
const user = await User.findById(userId);
if (!user) return res.status(404).json({ message: 'User not found' });
user.group = group || 'General';
await user.save();
res.json({ message: 'User assigned to group successfully', user });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/users/:userId/history
// @desc Get a user's test history
// @access hr
router.get('/users/:userId/history', async (req, res) => {
try {
const { userId } = req.params;
const user = await User.findById(userId).select('-password');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const submissions = await Submission.find({ studentId: userId })
.populate('quizId', 'title timer totalQuestions category')
.sort({ submittedAt: -1 });
res.json({ user, submissions });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/submissions/:submissionId
// @desc Get detailed submission - answers, correct answers, scores
// @access hr
router.get('/submissions/:submissionId', async (req, res) => {
try {
const { submissionId } = req.params;
const submission = await Submission.findById(submissionId)
.populate('studentId', 'name email')
.populate('quizId', 'title timer totalQuestions');
if (!submission) {
return res.status(404).json({ message: 'Submission not found' });
}
// Get all questions for this quiz
const questions = await Question.find({ quizId: submission.quizId._id });
// Build detailed result with question, student answer, correct answer
const detailedAnswers = questions.map(q => {
const studentAnswer = submission.answers.find(
a => a.questionId.toString() === q._id.toString()
);
return {
questionId: q._id,
question: q.question,
options: q.options,
type: q.type,
correctAnswers: q.correctAnswers,
studentAnswers: studentAnswer ? studentAnswer.selectedAnswers : [],
isCorrect: (studentAnswer && studentAnswer.selectedAnswers)
? checkAnswersMatch(studentAnswer.selectedAnswers, q.correctAnswers)
: false
};
});
res.json({
submission: {
id: submission._id,
student: submission.studentId,
quiz: submission.quizId,
score: submission.score,
totalMarks: submission.totalMarks,
percentage: submission.percentage,
timeTaken: submission.timeTaken,
submittedAt: submission.submittedAt
},
detailedAnswers
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============ QUIZ MANAGEMENT ============
// @route POST /api/hr/quiz/create
// @desc Create quiz with Excel upload
// @access hr
router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => {
try { try {
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups } = req.body; const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups } = req.body;
...@@ -30,6 +244,7 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => ...@@ -30,6 +244,7 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
return res.status(400).json({ message: 'Please upload an Excel file with questions' }); return res.status(400).json({ message: 'Please upload an Excel file with questions' });
} }
// Parse Excel file
const workbook = xlsx.readFile(req.file.path); const workbook = xlsx.readFile(req.file.path);
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
...@@ -40,6 +255,7 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => ...@@ -40,6 +255,7 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
return res.status(400).json({ message: 'Excel file is empty' }); return res.status(400).json({ message: 'Excel file is empty' });
} }
// Parse assignees
let parsedAssignees = []; let parsedAssignees = [];
if (assignees) { if (assignees) {
try { parsedAssignees = JSON.parse(assignees); } catch (e) { parsedAssignees = []; } try { parsedAssignees = JSON.parse(assignees); } catch (e) { parsedAssignees = []; }
...@@ -50,6 +266,7 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => ...@@ -50,6 +266,7 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
try { parsedGroups = JSON.parse(assignedGroups); } catch (e) { parsedGroups = []; } try { parsedGroups = JSON.parse(assignedGroups); } catch (e) { parsedGroups = []; }
} }
// Create quiz
const quiz = await Quiz.create({ const quiz = await Quiz.create({
title, title,
timer: parseInt(timer), timer: parseInt(timer),
...@@ -63,62 +280,305 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) => ...@@ -63,62 +280,305 @@ router.post('/quiz/create', upload.single('questionsFile'), async (req, res) =>
assignedGroups: parsedGroups assignedGroups: parsedGroups
}); });
// Parse and create questions
const questions = parseExcelQuestions(data, quiz._id); const questions = parseExcelQuestions(data, quiz._id);
console.log("PARSED QUESTIONS:", questions);
await Question.insertMany(questions); await Question.insertMany(questions);
// Clean up uploaded file
fs.unlinkSync(req.file.path); fs.unlinkSync(req.file.path);
res.status(201).json({ res.status(201).json({
message: 'Quiz created successfully', message: 'Quiz created successfully',
quiz: { id: quiz._id, title: quiz.title, timer: quiz.timer, totalQuestions: quiz.totalQuestions, category: quiz.category } quiz: {
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: quiz.totalQuestions,
category: quiz.category
}
}); });
} catch (error) { } catch (error) {
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); if (req.file && fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route POST /api/hr/quiz/create-manual // @route POST /api/hr/quiz/generate-ai
// @desc Create quiz manually (for AI-generated quizzes) // @desc Generate quiz using local Ollama LLM — AI determines title, timer, and questions from prompt
// @access HR // @access hr
router.post('/quiz/create-manual', async (req, res) => { router.post('/quiz/generate-ai', async (req, res) => {
try {
const { prompt, difficulty, assignToAll, assignees, assignedGroups } = req.body;
if (!prompt || !prompt.trim()) {
return res.status(400).json({ message: 'Please provide a prompt describing the quiz you want.' });
}
const ollamaUrl = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const ollamaModel = process.env.OLLAMA_MODEL || 'llama3.2';
const diffLevel = difficulty || 'medium';
const aiPrompt = `Based on this request: "${prompt}"
Generate a quiz at ${diffLevel} difficulty level. You must determine:
1. A short quiz title (max 5 words)
2. A short topic label (max 3 words, e.g. "Computer Networks", "Data Structures")
3. A reasonable time limit in minutes
4. The appropriate number of questions
CRITICAL RULES:
- Each question must have exactly 4 options.
- The "correct" field MUST contain the EXACT FULL TEXT of the correct option. DO NOT write "option1" or "option2" — write the actual answer text.
- For multiple correct answers, comma-separate the full text of each correct option.
- Return ONLY a valid JSON object, no markdown fences, no explanation.
EXAMPLE (notice "correct" contains the actual answer text, NOT "option1"):
{
"title": "JavaScript Basics",
"topic": "JavaScript",
"timer": 10,
"questions": [
{
"question": "What does DOM stand for?",
"option1": "Document Object Model",
"option2": "Data Object Manager",
"option3": "Digital Output Mode",
"option4": "Document Oriented Markup",
"correct": "Document Object Model"
}
]
}
IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the option key. Output ONLY the JSON.`;
console.log(`Calling Ollama at ${ollamaUrl}/api/chat with model ${ollamaModel}...`);
const ollamaResponse = await fetch(`${ollamaUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: ollamaModel,
messages: [
{ role: 'system', content: 'You are a quiz generator. Output ONLY valid JSON. The "correct" field must always contain the EXACT TEXT of the correct answer option, never "option1" or "option2".' },
{ role: 'user', content: aiPrompt }
],
stream: false,
options: { temperature: 0.7 }
})
});
if (!ollamaResponse.ok) {
const errText = await ollamaResponse.text();
console.error('Ollama error:', errText);
return res.status(500).json({ message: `Ollama error (${ollamaResponse.status}): Make sure Ollama is running with "${ollamaModel}" model pulled.` });
}
const ollamaData = await ollamaResponse.json();
let responseText = (ollamaData.message?.content || '').trim();
console.log('Ollama raw response length:', responseText.length);
// Strip markdown code fences if present
if (responseText.startsWith('```')) {
responseText = responseText.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
}
// Try to extract JSON object from response
const jsonObjMatch = responseText.match(/\{[\s\S]*\}/);
if (jsonObjMatch) {
responseText = jsonObjMatch[0];
}
let aiResult;
try {
aiResult = JSON.parse(responseText);
} catch (parseErr) {
console.error('AI response parse error:', parseErr);
console.error('Raw response:', responseText.substring(0, 500));
return res.status(500).json({ message: 'Failed to parse AI response. The LLM did not return valid JSON. Please try again.' });
}
// Extract metadata from AI response
const quizTitle = aiResult.title || 'AI Generated Quiz';
const quizTopic = aiResult.topic || quizTitle;
const quizTimer = parseInt(aiResult.timer) || 15;
const aiQuestions = aiResult.questions;
if (!Array.isArray(aiQuestions) || aiQuestions.length === 0) {
return res.status(500).json({ message: 'AI returned empty or invalid questions. Please try again.' });
}
// Parse AI output into our question format
const parsedQuestions = aiQuestions.map((q) => {
const options = [
(q.option1 || '').toString().trim(),
(q.option2 || '').toString().trim(),
(q.option3 || '').toString().trim(),
(q.option4 || '').toString().trim()
].filter(o => o.length > 0);
// Build a map for "option1" -> actual text fallback
const optionKeyMap = {
'option1': options[0] || '',
'option2': options[1] || '',
'option3': options[2] || '',
'option4': options[3] || ''
};
const correctStr = (q.correct || '').toString().trim();
const correctArr = correctStr.split(',').map(s => s.trim());
// First: try to match correct values against actual option text
let correctAnswers = options.filter(opt => correctArr.includes(opt));
// Fallback: if LLM returned "option1", "option2" keys instead of text
if (correctAnswers.length === 0) {
correctAnswers = correctArr
.map(c => optionKeyMap[c.toLowerCase()] || '')
.filter(c => c.length > 0);
}
// Last resort: if still empty, try case-insensitive match
if (correctAnswers.length === 0) {
correctAnswers = options.filter(opt =>
correctArr.some(c => c.toLowerCase() === opt.toLowerCase())
);
}
// Absolute fallback: use first option
if (correctAnswers.length === 0 && options.length > 0) {
console.warn(`Warning: Could not match correct answer "${correctStr}" for question "${q.question}". Using first option as fallback.`);
correctAnswers.push(options[0]);
}
return {
question: (q.question || '').toString().trim(),
options,
correctAnswers,
type: correctAnswers.length > 1 ? 'mcq' : 'single'
};
}).filter(q => q.question && q.options.length === 4 && q.correctAnswers.length > 0);
if (parsedQuestions.length === 0) {
return res.status(500).json({ message: 'Could not parse any valid questions from AI response.' });
}
// Log parsed questions for debugging
console.log('Parsed questions sample:', JSON.stringify(parsedQuestions[0], null, 2));
// Create quiz in DB
const quiz = await Quiz.create({
title: quizTitle,
timer: quizTimer,
totalQuestions: parsedQuestions.length,
createdBy: req.user._id,
category: quizTopic,
difficulty: diffLevel,
assignToAll: assignToAll === 'true' || assignToAll === true,
assignees: assignees || [],
assignedGroups: assignedGroups || [],
generatedByAI: true
});
const questionDocs = parsedQuestions.map(q => ({
quizId: quiz._id,
question: q.question,
options: q.options,
correctAnswers: q.correctAnswers,
type: q.type
}));
await Question.insertMany(questionDocs);
res.status(201).json({
message: 'AI Quiz generated successfully',
quiz: {
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: parsedQuestions.length,
category: quiz.category
},
questions: parsedQuestions
});
} catch (error) {
console.error('AI generation error:', error);
if (error.cause?.code === 'ECONNREFUSED') {
return res.status(500).json({ message: 'Cannot connect to Ollama. Make sure Ollama is running (ollama serve).' });
}
res.status(500).json({ message: 'AI generation failed: ' + (error.message || 'Unknown error') });
}
});
// @route POST /api/hr/quiz/create-manual
// @desc Create quiz manually (for AI-generated quizzes or manual entry)
// @access hr
router.post('/quiz/create-manual', async (req, res) => {
try { try {
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions, generatedByAI } = req.body; const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions, generatedByAI } = req.body;
if (!title || !timer) return res.status(400).json({ message: 'Please provide quiz title and timer' }); if (!title || !timer) {
if (!questions || questions.length === 0) return res.status(400).json({ message: 'Please provide at least one question' }); return res.status(400).json({ message: 'Please provide quiz title and timer' });
}
if (!questions || questions.length === 0) {
return res.status(400).json({ message: 'Please provide at least one question' });
}
// Create quiz
const quiz = await Quiz.create({ const quiz = await Quiz.create({
title, timer: parseInt(timer), totalQuestions: questions.length, createdBy: req.user._id, title,
category: category || 'General', difficulty: difficulty || 'Intermediate', topic: topic || '', timer: parseInt(timer),
assignToAll: assignToAll || false, assignees: assignees || [], assignedGroups: assignedGroups || [], totalQuestions: questions.length,
createdBy: req.user._id,
category: category || 'General',
difficulty: difficulty || 'Intermediate',
topic: topic || '',
assignToAll: assignToAll || false,
assignees: assignees || [],
assignedGroups: assignedGroups || [],
generatedByAI: generatedByAI || false generatedByAI: generatedByAI || false
}); });
// Create questions
const questionDocs = questions.map(q => ({ const questionDocs = questions.map(q => ({
quizId: quiz._id, question: q.question, options: q.options, quizId: quiz._id,
correctAnswers: q.correctAnswers, type: q.correctAnswers.length > 1 ? 'mcq' : 'single' question: q.question,
options: q.options,
correctAnswers: q.correctAnswers,
type: q.correctAnswers.length > 1 ? 'mcq' : 'single'
})); }));
await Question.insertMany(questionDocs); await Question.insertMany(questionDocs);
res.status(201).json({ res.status(201).json({
message: 'Quiz created successfully', message: 'Quiz created successfully',
quiz: { id: quiz._id, title: quiz.title, timer: quiz.timer, totalQuestions: quiz.totalQuestions, category: quiz.category } quiz: {
id: quiz._id,
title: quiz.title,
timer: quiz.timer,
totalQuestions: quiz.totalQuestions,
category: quiz.category
}
}); });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route GET /api/hr/quizzes // @route GET /api/hr/quizzes
// @desc Get quizzes created by this HR // @desc Get all quizzes with attempt count
// @access HR // @access hr
router.get('/quizzes', async (req, res) => { router.get('/quizzes', async (req, res) => {
try { try {
const quizzes = await Quiz.find({ createdBy: req.user._id }) const quizzes = await Quiz.find()
.populate('createdBy', 'name email') .populate('createdBy', 'name email')
.sort({ createdAt: -1 }); .sort({ createdAt: -1 });
// Get attempt counts for each quiz
const quizzesWithAttempts = await Promise.all( const quizzesWithAttempts = await Promise.all(
quizzes.map(async (quiz) => { quizzes.map(async (quiz) => {
const attemptCount = await Submission.countDocuments({ quizId: quiz._id }); const attemptCount = await Submission.countDocuments({ quizId: quiz._id });
...@@ -130,41 +590,50 @@ router.get('/quizzes', async (req, res) => { ...@@ -130,41 +590,50 @@ router.get('/quizzes', async (req, res) => {
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route GET /api/hr/quiz/:quizId // @route GET /api/hr/quiz/:quizId
// @desc Get quiz details for editing // @desc Get quiz with its questions for editing
// @access HR // @access hr
router.get('/quiz/:quizId', async (req, res) => { router.get('/quiz/:quizId', async (req, res) => {
try { try {
const quiz = await Quiz.findOne({ _id: req.params.quizId, createdBy: req.user._id }) const { quizId } = req.params;
const quiz = await Quiz.findById(quizId)
.populate('createdBy', 'name email')
.populate('assignees', 'name email'); .populate('assignees', 'name email');
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const questions = await Question.find({ quizId: quiz._id }); if (!quiz) {
const attemptCount = await Submission.countDocuments({ quizId: quiz._id }); return res.status(404).json({ message: 'Quiz not found' });
}
const questions = await Question.find({ quizId });
const attemptCount = await Submission.countDocuments({ quizId });
res.json({ quiz, questions, attemptCount }); res.json({ quiz, questions, attemptCount });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route PUT /api/hr/quiz/:quizId // @route PUT /api/hr/quiz/:quizId
// @desc Edit quiz (only if no attempts) // @desc Edit quiz (only if no attempts have been made)
// @access HR // @access hr
router.put('/quiz/:quizId', async (req, res) => { router.put('/quiz/:quizId', async (req, res) => {
try { try {
const { quizId } = req.params; const { quizId } = req.params;
const quiz = await Quiz.findOne({ _id: quizId, createdBy: req.user._id });
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
// Check if any attempts exist
const attemptCount = await Submission.countDocuments({ quizId }); const attemptCount = await Submission.countDocuments({ quizId });
if (attemptCount > 0) { if (attemptCount > 0) {
return res.status(403).json({ message: 'This quiz cannot be edited because it has already been attempted.', attemptCount }); return res.status(403).json({
message: 'This quiz cannot be edited because it has already been attempted by users.',
attemptCount
});
} }
const { title, timer, category, difficulty, assignToAll, assignees, assignedGroups, questions } = req.body; const { title, timer, category, difficulty, assignToAll, assignees, assignedGroups, questions } = req.body;
// Update quiz metadata
const updateData = {}; const updateData = {};
if (title) updateData.title = title; if (title) updateData.title = title;
if (timer) updateData.timer = parseInt(timer); if (timer) updateData.timer = parseInt(timer);
...@@ -174,152 +643,137 @@ router.put('/quiz/:quizId', async (req, res) => { ...@@ -174,152 +643,137 @@ router.put('/quiz/:quizId', async (req, res) => {
if (assignees) updateData.assignees = assignees; if (assignees) updateData.assignees = assignees;
if (assignedGroups) updateData.assignedGroups = assignedGroups; if (assignedGroups) updateData.assignedGroups = assignedGroups;
// If questions are provided, replace them
if (questions && questions.length > 0) { if (questions && questions.length > 0) {
await Question.deleteMany({ quizId }); await Question.deleteMany({ quizId });
const questionDocs = questions.map(q => ({ const questionDocs = questions.map(q => ({
quizId, question: q.question, options: q.options, quizId,
correctAnswers: q.correctAnswers, type: q.correctAnswers.length > 1 ? 'mcq' : 'single' question: q.question,
options: q.options,
correctAnswers: q.correctAnswers,
type: q.correctAnswers.length > 1 ? 'mcq' : 'single'
})); }));
await Question.insertMany(questionDocs);
await Question.insertMany(questions);
updateData.totalQuestions = questions.length; updateData.totalQuestions = questions.length;
} }
const updated = await Quiz.findByIdAndUpdate(quizId, updateData, { new: true }); const quiz = await Quiz.findByIdAndUpdate(quizId, updateData, { new: true });
res.json({ message: 'Quiz updated successfully', quiz: updated });
res.json({ message: 'Quiz updated successfully', quiz });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route DELETE /api/hr/quiz/:quizId // @route PUT /api/hr/quiz/:quizId/assign
// @desc Delete quiz (only if no attempts) // @desc Assign quiz to users/groups
// @access HR // @access hr
router.delete('/quiz/:quizId', async (req, res) => { router.put('/quiz/:quizId/assign', async (req, res) => {
try { try {
const { quizId } = req.params; const { quizId } = req.params;
const quiz = await Quiz.findOne({ _id: quizId, createdBy: req.user._id }); const { assignToAll, assignees, assignedGroups } = req.body;
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const attemptCount = await Submission.countDocuments({ quizId }); const quiz = await Quiz.findByIdAndUpdate(quizId, {
if (attemptCount > 0) { assignToAll: assignToAll || false,
return res.status(403).json({ message: 'This quiz cannot be deleted because it has already been attempted.', attemptCount }); assignees: assignees || [],
} assignedGroups: assignedGroups || []
}, { new: true });
await Question.deleteMany({ quizId }); if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
await Quiz.findByIdAndDelete(quizId);
res.json({ message: 'Quiz deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/hr/candidates res.json({ message: 'Quiz assignment updated', quiz });
// @desc Get all candidates
// @access HR
router.get('/candidates', async (req, res) => {
try {
const candidates = await User.find({ role: 'candidate' }).select('-password').sort({ createdAt: -1 });
res.json({ users: candidates });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route GET /api/hr/candidates/:userId/history // @route DELETE /api/hr/quiz/:quizId
// @desc Get candidate test history // @desc Delete a quiz (only if no attempts have been made)
// @access HR // @access hr
router.get('/candidates/:userId/history', async (req, res) => { router.delete('/quiz/:quizId', async (req, res) => {
try { try {
const user = await User.findById(req.params.userId).select('-password'); const { quizId } = req.params;
if (!user) return res.status(404).json({ message: 'User not found' });
const submissions = await Submission.find({ studentId: req.params.userId })
.populate('quizId', 'title timer totalQuestions category')
.sort({ submittedAt: -1 });
res.json({ user, submissions }); // Check if any attempts exist
} catch (error) { const attemptCount = await Submission.countDocuments({ quizId });
res.status(500).json({ message: 'Server error', error: error.message }); if (attemptCount > 0) {
return res.status(403).json({
message: 'This quiz cannot be deleted because it has already been attempted by users.',
attemptCount
});
} }
});
// @route GET /api/hr/submissions/:submissionId
// @desc Get detailed submission
// @access HR
router.get('/submissions/:submissionId', async (req, res) => {
try {
const submission = await Submission.findById(req.params.submissionId)
.populate('studentId', 'name email')
.populate('quizId', 'title timer totalQuestions');
if (!submission) return res.status(404).json({ message: 'Submission not found' });
const questions = await Question.find({ quizId: submission.quizId._id });
const detailedAnswers = questions.map(q => { await Question.deleteMany({ quizId });
const studentAnswer = submission.answers.find(a => a.questionId.toString() === q._id.toString()); await Quiz.findByIdAndDelete(quizId);
return {
questionId: q._id, question: q.question, options: q.options, type: q.type,
correctAnswers: q.correctAnswers,
studentAnswers: studentAnswer ? studentAnswer.selectedAnswers : [],
isCorrect: (studentAnswer && studentAnswer.selectedAnswers) ? checkAnswersMatch(studentAnswer.selectedAnswers, q.correctAnswers) : false
};
});
res.json({ res.json({ message: 'Quiz deleted successfully' });
submission: {
id: submission._id, student: submission.studentId, quiz: submission.quizId,
score: submission.score, totalMarks: submission.totalMarks, percentage: submission.percentage,
timeTaken: submission.timeTaken, submittedAt: submission.submittedAt
},
detailedAnswers
});
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route GET /api/hr/categories // @route GET /api/hr/categories
router.get('/categories', async (req, res) => { // @desc Get all unique quiz categories
// @access hr
router.get('/categories', async (req, res) => {
try { try {
const categories = await Quiz.distinct('category'); const categories = await Quiz.distinct('category');
res.json({ categories }); res.json({ categories });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route GET /api/hr/groups // @route GET /api/hr/groups
router.get('/groups', async (req, res) => { // @desc Get all unique user groups
// @access hr
router.get('/groups', async (req, res) => {
try { try {
const groups = await Group.find().sort({ name: 1 }); const groups = await Group.find().sort({ name: 1 });
const groupNames = groups.map(g => g.name); const groupNames = groups.map(g => g.name);
// Fetch legacy groups from users just to be safe
const userGroups = await User.distinct('group'); const userGroups = await User.distinct('group');
const allGroups = [...new Set([...groupNames, ...userGroups])].filter(g => g && g !== 'General'); const allGroups = [...new Set([...groupNames, ...userGroups])].filter(g => g && g !== 'General');
res.json({ groups: allGroups }); res.json({ groups: allGroups });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route POST /api/hr/groups // @route POST /api/admin/groups
router.post('/groups', async (req, res) => { // @desc Create a new group
// @access Admin
router.post('/groups', async (req, res) => {
try { try {
const { name } = req.body; const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ message: 'Group name is required' });
if (!name || !name.trim()) {
return res.status(400).json({ message: 'Group name is required' });
}
// Check if already exists
const existing = await Group.findOne({ name: name.trim() }); const existing = await Group.findOne({ name: name.trim() });
if (existing) return res.status(400).json({ message: 'Group already exists' }); if (existing) {
return res.status(400).json({ message: 'Group already exists' });
}
const group = await Group.create({ name: name.trim() }); const group = await Group.create({ name: name.trim() });
res.status(201).json({ message: 'Group created successfully', group: group.name }); res.status(201).json({ message: 'Group created successfully', group: group.name });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route PUT /api/hr/groups/:oldName // @route PUT /api/hr/groups/:oldName
router.put('/groups/:oldName', async (req, res) => { // @desc Edit a group name
// @access hr
router.put('/groups/:oldName', async (req, res) => {
try { try {
const { oldName } = req.params; const { oldName } = req.params;
const { newName } = req.body; const { newName } = req.body;
...@@ -339,10 +793,12 @@ router.put('/groups/:oldName', async (req, res) => { ...@@ -339,10 +793,12 @@ router.put('/groups/:oldName', async (req, res) => {
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route DELETE /api/hr/groups/:name // @route DELETE /api/hr/groups/:name
router.delete('/groups/:name', async (req, res) => { // @desc Delete a group
// @access hr
router.delete('/groups/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
...@@ -354,48 +810,38 @@ router.delete('/groups/:name', async (req, res) => { ...@@ -354,48 +810,38 @@ router.delete('/groups/:name', async (req, res) => {
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route PUT /api/hr/users/:userId/group // @route GET /api/hr/stats
// @desc Assign user to a group explicitly // @desc Get dashboard statistics
// @access HR // @access hr
router.put('/users/:userId/group', async (req, res) => { router.get('/stats', async (req, res) => {
try { try {
const { userId } = req.params; const totalUsers = await User.countDocuments({ role: 'candidate' });
const { group } = req.body; const totalHR = await User.countDocuments({ role: 'hr' });
const totalQuizzes = await Quiz.countDocuments();
const user = await User.findById(userId); const totalSubmissions = await Submission.countDocuments();
if (!user) return res.status(404).json({ message: 'User not found' }); const onlineUsers = await User.countDocuments({ isLoggedIn: true, role: { $in: ['candidate', 'hr'] } });
// Only allow HR to modify candidates (optional security check) // Recent submissions
if (user.role === 'admin' || user.role === 'hr') { const recentSubmissions = await Submission.find()
return res.status(403).json({ message: 'HR can only assign candidates to groups' }); .populate('studentId', 'name email')
} .populate('quizId', 'title')
.sort({ submittedAt: -1 })
user.group = group || 'General'; .limit(5);
await user.save();
res.json({ message: 'User assigned to group successfully', user }); res.json({
stats: { totalUsers, totalHR, totalQuizzes, totalSubmissions, onlineUsers },
recentSubmissions
});
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
// @route GET /api/hr/stats
router.get('/stats', async (req, res) => {
try {
const totalCandidates = await User.countDocuments({ role: 'candidate' });
const myQuizzes = await Quiz.countDocuments({ createdBy: req.user._id });
const myQuizIds = (await Quiz.find({ createdBy: req.user._id }).select('_id')).map(q => q._id);
const totalSubmissions = await Submission.countDocuments({ quizId: { $in: myQuizIds } });
res.json({ stats: { totalCandidates, myQuizzes, totalSubmissions } }); // ============ HELPER FUNCTIONS ============
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
function parseExcelQuestions(data, quizId) { function parseExcelQuestions(data, quizId) {
return data.map(row => { return data.map(row => {
const question = row['Question'] || row['question'] || ''; const question = row['Question'] || row['question'] || '';
const option1 = row['Option1'] || row['option1'] || row['Option 1'] || ''; const option1 = row['Option1'] || row['option1'] || row['Option 1'] || '';
...@@ -403,54 +849,140 @@ function parseExcelQuestions(data, quizId) { ...@@ -403,54 +849,140 @@ function parseExcelQuestions(data, quizId) {
const option3 = row['Option3'] || row['option3'] || row['Option 3'] || ''; const option3 = row['Option3'] || row['option3'] || row['Option 3'] || '';
const option4 = row['Option4'] || row['option4'] || row['Option 4'] || ''; const option4 = row['Option4'] || row['option4'] || row['Option 4'] || '';
const correct = row['Correct'] || row['correct'] || row['Answer'] || row['answer'] || ''; const correct = row['Correct'] || row['correct'] || row['Answer'] || row['answer'] || '';
const options = [
option1.toString().trim(),
option2.toString().trim(),
option3.toString().trim(),
option4.toString().trim()
];
const correctStr = correct.toString().trim(); const correctStr = correct.toString().trim();
const correctAnswers = correctStr.includes(',') ? correctStr.split(',').map(a => a.trim()) : [correctStr]; const correctArr = correctStr.split(',').map(s => s.trim());
// Save exact literal text of correct answer
const correctAnswers = options
.filter(opt => correctArr.includes(opt));
if (correctAnswers.length === 0) {
throw new Error(`Correct answer "${correctStr}" not matching options`);
}
const type = correctAnswers.length > 1 ? 'mcq' : 'single';
return { return {
quizId, question: question.toString().trim(), quizId,
options: [option1.toString().trim(), option2.toString().trim(), option3.toString().trim(), option4.toString().trim()], question: question.toString().trim(),
correctAnswers, type: correctAnswers.length > 1 ? 'mcq' : 'single' options: [
option1.toString().trim(),
option2.toString().trim(),
option3.toString().trim(),
option4.toString().trim()
],
correctAnswers,
type
}; };
}); });
} }
function checkAnswersMatch(arr1, arr2) { function checkAnswersMatch(arr1, arr2) {
if (!arr1 || !arr2 || arr1.length !== arr2.length) return false; if (!arr1 || !arr2 || arr1.length !== arr2.length) return false;
const a = arr1.map(x => x.toString().trim().toLowerCase()).sort().join('||'); const a = arr1.map(x => x.toString().trim().toLowerCase()).sort().join('||');
const b = arr2.map(x => x.toString().trim().toLowerCase()).sort().join('||'); const b = arr2.map(x => x.toString().trim().toLowerCase()).sort().join('||');
return a === b; return a === b;
} }
// @route PUT /api/hr/users/:userId/level // @route GET /api/hr/quiz/:quizId/assign-candidates
// @desc Update a candidate's level // @desc Get candidates filtered by quiz category + difficulty using per-topic comfortLevel
// @access HR // @access hr
router.put('/users/:userId/level', async (req, res) => { router.get('/quiz/:quizId/assign-candidates', async (req, res) => {
try { try {
const { userId } = req.params; const quiz = await Quiz.findById(req.params.quizId);
const { level } = req.body; if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const validLevels = ['beginner', 'intermediate', 'advanced', 'expert']; const allCandidates = await User.find({ role: 'candidate' });
if (!level || !validLevels.includes(level)) {
return res.status(400).json({ message: 'Invalid level.' }); // Quiz parameters
const quizDifficulty = quiz.difficulty ? quiz.difficulty.toLowerCase() : '';
const quizCategory = quiz.category ? quiz.category.toLowerCase() : '';
// Comfort level → proficiency mapping:
// 0-25 = beginner
// 26-50 = intermediate
// 51-75 = advanced
// 76-100 = expert
function comfortToProficiency(comfort) {
if (comfort <= 25) return 'beginner';
if (comfort <= 50) return 'intermediate';
if (comfort <= 75) return 'advanced';
return 'expert';
} }
const user = await User.findById(userId); // Quiz difficulty → required proficiency mapping:
if (!user) return res.status(404).json({ message: 'User not found' }); // Easy → beginner
if (user.role !== 'candidate') return res.status(400).json({ message: 'Level can only be set for candidates' }); // Medium → intermediate, advanced
// Hard → expert
function getRequiredProficiencies(difficulty) {
if (difficulty === 'easy') return ['beginner'];
if (difficulty === 'medium') return ['intermediate', 'advanced'];
if (difficulty === 'hard') return ['expert'];
return ['beginner', 'intermediate', 'advanced', 'expert']; // no difficulty = all
}
const previousLevel = user.level || 'beginner'; const requiredProficiencies = getRequiredProficiencies(quizDifficulty);
user.level = level; const filtered = [];
await user.save();
for (const candidate of allCandidates) {
// If quiz has no category, show all candidates (no topic filter)
if (!quizCategory) {
filtered.push({
_id: candidate._id,
name: candidate.name,
email: candidate.email,
level: candidate.level,
topicsOfInterest: candidate.topicsOfInterest
});
continue;
}
// Check if candidate has a matching topic in their interests
if (!candidate.topicsOfInterest || candidate.topicsOfInterest.length === 0) continue;
const matchingTopic = candidate.topicsOfInterest.find(t => {
const ct = t.topic.toLowerCase();
return ct.includes(quizCategory) || quizCategory.includes(ct);
});
if (!matchingTopic) continue; // Candidate has no interest in this category
// Map their comfort level for this specific topic to a proficiency
const candidateProficiency = comfortToProficiency(matchingTopic.comfortLevel);
// Check if their proficiency matches the quiz difficulty requirement
if (!requiredProficiencies.includes(candidateProficiency)) continue;
filtered.push({
_id: candidate._id,
name: candidate.name,
email: candidate.email,
level: candidate.level,
topicsOfInterest: candidate.topicsOfInterest,
matchedTopic: matchingTopic.topic,
matchedComfort: matchingTopic.comfortLevel,
matchedProficiency: candidateProficiency
});
}
const assignedIds = quiz.assignees ? quiz.assignees.map(id => id.toString()) : [];
res.json({ res.json({
message: `Level changed from ${previousLevel} to ${level}`, quizTitle: quiz.title,
user: { _id: user._id, name: user.name, level: user.level }, quizDifficulty: quiz.difficulty,
previousLevel, quizCategory: quiz.category,
newLevel: level assignedIds: assignedIds,
filteredCandidates: filtered
}); });
} catch (error) { } catch (error) {
res.status(500).json({ message: 'Server error', error: error.message }); res.status(500).json({ message: 'Server error', error: error.message });
} }
}); });
module.exports = router; module.exports = router;
...@@ -82,27 +82,35 @@ export const routes: Routes = [ ...@@ -82,27 +82,35 @@ export const routes: Routes = [
{ {
path: 'quizzes', path: 'quizzes',
loadComponent: () => import('./pages/hr/quizzes/quizzes').then(m => m.HRQuizzesComponent) loadComponent: () => import('./pages/hr/quizzes/quizzes').then(m => m.HRQuizzesComponent)
},
{
path: 'users/:userId/history',
loadComponent: () => import('./pages/hr/user-history/user-history').then(m => m.HRUserHistoryComponent)
}, },
{ {
path: 'create-quiz', path: 'create-quiz',
loadComponent: () => import('./pages/hr/create-quiz/create-quiz').then(m => m.HRCreateQuizComponent) loadComponent: () => import('./pages/hr/create-quiz/create-quiz').then(m => m.HRCreateQuizComponent)
}, },
{ {
path: 'candidates', path: 'users',
loadComponent: () => import('./pages/hr/candidates/candidates').then(m => m.HRCandidatesComponent) loadComponent: () => import('./pages/hr/users/users').then(m => m.Users)
}, },
{ {
path: 'manage-groups', path: 'manage-groups',
loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent) loadComponent: () => import('./pages/hr/manage-groups/manage-groups').then(m => m.HRManageGroupsComponent)
},
{
path: 'candidates/:userId/history',
loadComponent: () => import('./pages/hr/candidate-history/candidate-history').then(m => m.HRCandidateHistoryComponent)
}, },
{ {
path: 'submissions/:submissionId', path: 'submissions/:submissionId',
loadComponent: () => import('./pages/hr/submission-detail/submission-detail').then(m => m.HRSubmissionDetailComponent) loadComponent: () => import('./pages/hr/submission-detail/submission-detail').then(m => m.HRSubmissionDetailComponent)
}, },
{
path: 'quiz/:quizId/assign',
loadComponent: () => import('./pages/hr/assign-quiz/assign-quiz').then(m => m.HRAssignQuizComponent)
},
{
path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/hr/edit-quiz/edit-quiz').then(m => m.HREditQuizComponent)
},
] ]
}, },
......
...@@ -37,9 +37,9 @@ ...@@ -37,9 +37,9 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@for (item of navItems(); track item.route) { @for (item of navItems(); track item.route) {
@if (item.route === '/admin/users' || item.route === '/hr/candidates') { @if (item.route === '/admin/users' || item.route === '/hr/users') {
<a class="nav-item" (click)="handleNavClick(item, $event)" href="javascript:void(0)" <a class="nav-item" (click)="handleNavClick(item, $event)" href="javascript:void(0)"
[class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/candidates') || router.url.includes('manage-groups')"> [class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/users') || router.url.includes('manage-groups')">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span> <span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span> <span class="nav-label">{{ item.label }}</span>
</a> </a>
......
...@@ -48,7 +48,7 @@ export class LayoutComponent { ...@@ -48,7 +48,7 @@ export class LayoutComponent {
{ icon: 'dashboard', label: 'Dashboard', route: '/hr/dashboard' }, { icon: 'dashboard', label: 'Dashboard', route: '/hr/dashboard' },
{ icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' }, { icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' }, { icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' },
{ icon: 'people', label: 'Candidates', route: '/hr/candidates' }, { icon: 'people', label: 'Candidates', route: '/hr/users' },
]; ];
case 'candidate': case 'candidate':
return [ return [
...@@ -92,7 +92,7 @@ export class LayoutComponent { ...@@ -92,7 +92,7 @@ export class LayoutComponent {
} }
handleNavClick(item: NavItem, event: Event): void { handleNavClick(item: NavItem, event: Event): void {
if (item.route === '/admin/users' || item.route === '/hr/candidates') { if (item.route === '/admin/users' || item.route === '/hr/users') {
event.preventDefault(); event.preventDefault();
this.uiService.showManageUsersPopup.set(true); this.uiService.showManageUsersPopup.set(true);
} else { } else {
...@@ -105,7 +105,7 @@ export class LayoutComponent { ...@@ -105,7 +105,7 @@ export class LayoutComponent {
} }
getUsersRoute(): string { getUsersRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/candidates' : '/admin/users'; return this.authService.getUserRole() === 'hr' ? '/hr/users' : '/admin/users';
} }
getManageGroupsRoute(): string { getManageGroupsRoute(): string {
......
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
} @else { } @else {
<div class="users-grid"> <div class="users-grid">
@for (user of users(); track user._id) { @for (user of users(); track user._id) {
<a [routerLink]="['/admin/users', user._id, 'history']" class="user-card"> <a [routerLink]="['/admin/user-history/', user._id, 'history']" class="user-card">
<div class="user-card-avatar">{{ user.name?.charAt(0) || '?' }}</div> <div class="user-card-avatar">{{ user.name?.charAt(0) || '?' }}</div>
<div class="user-card-info"> <div class="user-card-info">
<div class="name-row"> <div class="name-row">
......
/* ===== Page Container: Match manage-groups exactly ===== */
.page-container { max-width: 1400px; padding: 32px 40px; margin: 0 auto; height: calc(100vh - 64px); display: flex; box-sizing: border-box; }
.split-view { gap: 32px; align-items: stretch; }
.main-workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-y: auto; padding-right: 16px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 8px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 0; }
/* ===== Quiz Info Bar ===== */
.quiz-info-bar { margin-bottom: 30px; }
.quiz-info-row {
display: flex;
gap: 40px;
flex-wrap: wrap;
}
.quiz-info-item {
display: flex;
align-items: center;
gap: 12px;
}
.quiz-info-item > .material-symbols-rounded {
font-size: 20px;
color: var(--accent-primary, #667eea);
background: rgba(102, 126, 234, 0.1);
padding: 10px;
border-radius: 10px;
}
.quiz-info-item div {
display: flex;
flex-direction: column;
gap: 2px;
}
.quiz-info-item small {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.quiz-info-item strong {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
}
/* ===== Section Title ===== */
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
}
.section-title .material-symbols-rounded {
font-size: 22px;
color: var(--accent-primary, #667eea);
}
.count-badge {
background: rgba(102, 126, 234, 0.1);
color: var(--accent-primary, #667eea);
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
margin-left: 4px;
}
/* ===== Available Candidates Pool ===== */
.available-pool {
display: flex;
flex-wrap: wrap;
gap: 16px;
min-height: 120px;
padding: 4px 0;
}
.empty-pool {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 48px 20px;
color: var(--text-muted);
background: var(--bg-hover);
border-radius: 12px;
border: 2px dashed var(--border-color);
text-align: center;
}
.empty-pool .material-symbols-rounded {
font-size: 40px;
margin-bottom: 8px;
opacity: 0.3;
}
.empty-pool p { margin: 0; font-size: 14px; }
/* ===== Candidate Cards (Left Pool) ===== */
.candidate-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: grab;
transition: all 0.2s ease;
min-width: 280px;
flex: 0 1 auto;
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
}
.candidate-card:hover {
border-color: var(--accent-primary, #667eea);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.08);
transform: translateY(-2px);
}
.candidate-card:active {
cursor: grabbing;
}
.candidate-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-input);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.candidate-details {
flex: 1;
min-width: 0;
}
.candidate-details h4 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.candidate-details p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.candidate-meta {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.comfort-tag {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.level-pill {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.level-pill[data-level="beginner"] { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.level-pill[data-level="intermediate"] { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.level-pill[data-level="advanced"] { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
.level-pill[data-level="expert"] { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
.drag-icon {
color: var(--text-muted);
opacity: 0.5;
font-size: 20px;
cursor: grab;
}
/* ===== SIDEBAR: Assigned (matches unassigned-sidebar from manage-groups) ===== */
.assigned-sidebar {
width: 340px;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
overflow: hidden;
background: var(--surface, var(--bg-card));
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 16px);
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(102,126,234,0.05);
}
.sidebar-title {
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-title .material-symbols-rounded {
color: #22c55e;
font-size: 20px;
}
.sidebar-title h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sidebar-badge {
background: #22c55e;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.assigned-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 200px;
}
.empty-assigned {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
opacity: 0.7;
min-height: 200px;
}
.empty-assigned .material-symbols-rounded {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.4;
}
.empty-assigned p {
margin: 0;
font-size: 13px;
}
/* Highlight when dragging over the assigned zone */
.assigned-list.cdk-drop-list-receiving {
background: rgba(102,126,234,0.04);
}
/* ===== Assigned Cards (Sidebar Items — match unassigned-card from manage-groups) ===== */
.assigned-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: grab;
transition: border-color 0.2s, box-shadow 0.2s;
position: relative;
}
.assigned-card:hover {
border-color: var(--accent-primary, #667eea);
box-shadow: 0 4px 12px rgba(102,126,234,0.08);
transform: translateY(-2px);
}
.assigned-card:active {
cursor: grabbing;
}
.assigned-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-input);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.assigned-details {
flex: 1;
min-width: 0;
}
.assigned-details h4 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assigned-details p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===== Sidebar Footer ===== */
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
}
.save-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 24px;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
font-size: 15px;
font-family: inherit;
background: var(--accent-gradient, linear-gradient(135deg, #667eea, #764ba2));
color: #fff;
box-shadow: 0 4px 15px rgba(102,126,234,0.3);
}
.save-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102,126,234,0.4);
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.save-toast {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #22c55e;
justify-content: center;
}
/* ===== CDK Drag & Drop (matches manage-groups exactly) ===== */
.custom-drag-placeholder {
opacity: 0.3;
background: rgba(102,126,234,0.1);
border: 2px dashed var(--accent-primary, #667eea);
border-radius: 12px;
min-height: 60px;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-preview {
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-radius: 12px;
opacity: 0.95;
z-index: 1000 !important;
cursor: grabbing !important;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.available-pool.cdk-drop-list-dragging .candidate-card:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.assigned-list.cdk-drop-list-dragging .assigned-card:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
/* ===== Loading State ===== */
.loading-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); gap: 16px; }
/* ===== Responsive ===== */
@media (max-width: 900px) {
.split-view { flex-direction: column; }
.assigned-sidebar { width: 100%; height: 400px; }
.page-container { height: auto; padding: 20px 16px; }
.quiz-info-row { flex-direction: column; gap: 16px; }
}
<div class="page-container animate-fade-in split-view" cdkDropListGroup>
<div class="main-workspace">
<div class="page-header">
<a routerLink="/hr/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Assign Quiz</h1>
<p class="page-subtitle">Drag candidates to assign them to <strong>{{ quizTitle() }}</strong></p>
</div>
<!-- Quiz Info Badge -->
<div class="quiz-info-bar card card-padding" style="margin-bottom: 30px;">
<div class="quiz-info-row">
<div class="quiz-info-item">
<span class="material-symbols-rounded">quiz</span>
<div>
<small>Quiz</small>
<strong>{{ quizTitle() }}</strong>
</div>
</div>
<div class="quiz-info-item">
<span class="material-symbols-rounded">speed</span>
<div>
<small>Difficulty</small>
<strong>{{ quizDifficulty() | titlecase }}</strong>
</div>
</div>
<div class="quiz-info-item">
<span class="material-symbols-rounded">category</span>
<div>
<small>Category</small>
<strong>{{ quizCategory() || 'General' }}</strong>
</div>
</div>
<div class="quiz-info-item">
<span class="material-symbols-rounded">filter_list</span>
<div>
<small>Filter Criteria</small>
<strong>{{ getFilterDescription() }}</strong>
</div>
</div>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p>Loading candidates...</p>
</div>
} @else {
<!-- Available Candidates Pool -->
<h2 class="section-title">
<span class="material-symbols-rounded">person_search</span>
Eligible Candidates
<span class="count-badge">{{ availableCandidates.length }}</span>
</h2>
<div class="available-pool" cdkDropList [cdkDropListData]="availableCandidates" (cdkDropListDropped)="drop($event)">
@if (availableCandidates.length === 0) {
<div class="empty-pool">
<span class="material-symbols-rounded">done_all</span>
<p>All matching candidates have been assigned!</p>
</div>
}
@for (candidate of availableCandidates; track candidate._id) {
<div class="candidate-card" cdkDrag [cdkDragData]="candidate">
<div class="candidate-avatar">{{ candidate.name.charAt(0).toUpperCase() }}</div>
<div class="candidate-details">
<h4>{{ candidate.name }}</h4>
<p>{{ candidate.email }}</p>
</div>
<div class="candidate-meta">
@if (candidate.matchedProficiency) {
<span class="level-pill" [attr.data-level]="candidate.matchedProficiency">{{ candidate.matchedProficiency | titlecase }}</span>
<span class="comfort-tag">{{ candidate.matchedComfort }}%</span>
} @else {
<span class="level-pill" [attr.data-level]="candidate.level">{{ candidate.level | titlecase }}</span>
}
</div>
<span class="material-symbols-rounded drag-icon">drag_indicator</span>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
}
</div>
<!-- Sidebar: Assigned Candidates -->
<aside class="assigned-sidebar card">
<div class="sidebar-header">
<div class="sidebar-title">
<span class="material-symbols-rounded">how_to_reg</span>
<h2>Assigned</h2>
</div>
<div class="sidebar-badge">{{ assignedCandidates.length }}</div>
</div>
<div class="assigned-list" cdkDropList [cdkDropListData]="assignedCandidates" (cdkDropListDropped)="drop($event)">
@if (assignedCandidates.length === 0) {
<div class="empty-assigned">
<span class="material-symbols-rounded">move_down</span>
<p>Drag candidates here to assign</p>
</div>
}
@for (candidate of assignedCandidates; track candidate._id) {
<div class="assigned-card" cdkDrag [cdkDragData]="candidate">
<div class="assigned-avatar">{{ candidate.name.charAt(0).toUpperCase() }}</div>
<div class="assigned-details">
<h4>{{ candidate.name }}</h4>
<p>{{ candidate.email }}</p>
</div>
<span class="material-symbols-rounded drag-icon">drag_indicator</span>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
<!-- Save Button -->
<div class="sidebar-footer">
@if (successMessage()) {
<span class="save-toast">
<span class="material-symbols-rounded">check_circle</span>
{{ successMessage() }}
</span>
}
<button class="btn btn-primary save-btn" (click)="saveAssignments()" [disabled]="isSaving()">
@if (isSaving()) {
<span class="spinner"></span> Saving...
} @else {
<span class="material-symbols-rounded">save</span> Save Assignments
}
</button>
</div>
</aside>
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CdkDragDrop, moveItemInArray, transferArrayItem, DragDropModule } from '@angular/cdk/drag-drop';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-assign-quiz',
imports: [CommonModule, RouterLink, FormsModule, DragDropModule],
templateUrl: './assign-quiz.html',
styleUrl: './assign-quiz.css',
})
export class HRAssignQuizComponent {
quizId = '';
quizTitle = signal('');
quizDifficulty = signal('');
quizCategory = signal('');
// Use plain arrays for CDK drag-drop (signals break mutation)
availableCandidates: any[] = [];
assignedCandidates: any[] = [];
loading = signal(true);
isSaving = signal(false);
successMessage = signal('');
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit() {
this.quizId = this.route.snapshot.paramMap.get('quizId') || '';
if (this.quizId) {
this.loadCandidates();
}
}
loadCandidates() {
this.loading.set(true);
this.quizService.getHRAssignCandidates(this.quizId).subscribe({
next: (res) => {
this.quizTitle.set(res.quizTitle);
this.quizDifficulty.set(res.quizDifficulty || 'General');
this.quizCategory.set(res.quizCategory || '');
// Only show filtered (recommended) candidates
const filtered = res.filteredCandidates || [];
const assigned: any[] = [];
const available: any[] = [];
filtered.forEach((c: any) => {
if (res.assignedIds.includes(c._id)) {
assigned.push(c);
} else {
available.push(c);
}
});
this.assignedCandidates = assigned;
this.availableCandidates = available;
this.loading.set(false);
},
error: (err) => {
console.error(err);
this.loading.set(false);
}
});
}
drop(event: CdkDragDrop<any[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex,
);
}
}
saveAssignments() {
this.isSaving.set(true);
const assignedIds = this.assignedCandidates.map(c => c._id);
console.log('assignedCandidates:', this.assignedCandidates);
console.log('assignedIds:', assignedIds);
this.quizService.assignHRQuiz(this.quizId, { assignees: assignedIds }).subscribe({
next: () => {
this.isSaving.set(false);
this.successMessage.set('Assignments saved successfully!');
setTimeout(() => this.successMessage.set(''), 3000);
},
error: () => {
this.isSaving.set(false);
alert('Failed to update assignments');
}
});
}
getFilterDescription(): string {
const d = this.quizDifficulty().toLowerCase();
const cat = this.quizCategory() || 'Any';
let levelDesc = 'All levels';
if (d === 'easy') levelDesc = 'Beginner (0-25% comfort)';
if (d === 'medium') levelDesc = 'Intermediate & Advanced (26-75%)';
if (d === 'hard') levelDesc = 'Expert (76-100% comfort)';
return `${levelDesc} in "${cat}"`;
}
}
.page-container { padding: 32px 40px; max-width: 1100px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; margin-bottom: 20px; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.user-header { display: flex; align-items: center; gap: 16px; margin-bottom: 32px; }
.user-avatar {
width: 56px; height: 56px; border-radius: var(--radius-lg);
background: var(--accent-gradient); display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; font-size: 24px;
}
.user-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0; }
.user-email { font-size: 14px; color: var(--text-muted); margin: 2px 0 0; }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
.percent-bar { width: 80px; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 8px; }
.percent-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.percent-fill.good { background: var(--success); }
.percent-fill.avg { background: var(--warning); }
.percent-fill.poor { background: var(--danger); }
.percent-text { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
<div class="page-container animate-fade-in">
<a routerLink="/hr/candidates" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Candidates
</a>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading history...</p></div>
} @else {
@if (user()) {
<div class="user-header">
<div class="user-avatar">{{ user().name?.charAt(0)?.toUpperCase() }}</div>
<div>
<h1>{{ user().name }}</h1>
<p class="user-email">{{ user().email }}</p>
</div>
</div>
}
<h2 class="section-title">Quiz History</h2>
@if (submissions().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">assignment</span>
<h3>No quizzes taken</h3>
<p>This candidate hasn't taken any quizzes yet.</p>
</div>
} @else {
<div class="table-container">
<table>
<thead>
<tr>
<th>Quiz</th>
<th>Score</th>
<th>Percentage</th>
<th>Time</th>
<th>Date</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@for (sub of submissions(); track sub._id) {
<tr>
<td><strong>{{ sub.quizId?.title || 'Deleted Quiz' }}</strong></td>
<td><span class="badge badge-primary">{{ sub.score }}/{{ sub.totalMarks }}</span></td>
<td>
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
[class.avg]="sub.percentage >= 40 && sub.percentage < 70"
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
</td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'mediumDate' }}</td>
<td><a [routerLink]="['/hr/submissions', sub._id]" class="btn btn-outline btn-sm">View</a></td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-candidate-history',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './candidate-history.html',
styleUrl: './candidate-history.css'
})
export class HRCandidateHistoryComponent implements OnInit {
userId = '';
user = signal<any>(null);
submissions = signal<any[]>([]);
loading = signal(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit(): void {
this.userId = this.route.snapshot.params['userId'];
this.quizService.getHRCandidateHistory(this.userId).subscribe({
next: (res) => {
this.user.set(res.user);
this.submissions.set(res.submissions);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
}
.page-container { padding: 32px 40px; max-width: 1200px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.candidates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.candidate-card {
display: flex; align-items: center; gap: 16px; padding: 20px;
text-decoration: none; cursor: pointer;
}
.candidate-avatar {
width: 48px; height: 48px; border-radius: var(--radius-md);
background: var(--accent-gradient); display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; font-size: 20px; flex-shrink: 0;
}
.candidate-info { flex: 1; min-width: 0; }
.candidate-info h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 2px; }
.candidate-info p { font-size: 13px; color: var(--text-secondary); margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.candidate-meta { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
.arrow-icon { color: var(--text-muted); font-size: 22px; transition: transform 0.2s; }
.candidate-card:hover .arrow-icon { transform: translateX(4px); color: var(--accent-primary); }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.candidates-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<h1>Candidates</h1>
<p class="page-subtitle">View all candidates and their quiz history</p>
</div>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading candidates...</p></div>
} @else if (candidates().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">people</span>
<h3>No candidates found</h3>
<p>No candidates have registered yet.</p>
</div>
} @else {
<div class="candidates-grid stagger-children">
@for (user of candidates(); track user._id) {
<a [routerLink]="['/hr/candidates', user._id, 'history']" class="card card-hover candidate-card">
<div class="candidate-avatar">{{ user.name?.charAt(0)?.toUpperCase() || '?' }}</div>
<div class="candidate-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
<span class="candidate-meta">
@if (user.group) { <span class="badge badge-primary">{{ user.group }}</span> }
Joined {{ user.createdAt | date:'mediumDate' }}
</span>
</div>
<span class="material-symbols-rounded arrow-icon">chevron_right</span>
</a>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-candidates',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './candidates.html',
styleUrl: './candidates.css'
})
export class HRCandidatesComponent implements OnInit {
candidates = signal<any[]>([]);
loading = signal(true);
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getHRCandidates().subscribe({
next: (res) => { this.candidates.set(res.candidates || res.users || []); this.loading.set(false); },
error: () => this.loading.set(false)
});
}
}
.page-container { padding: 32px 40px; max-width: 800px; } .page-container { padding: 32px 40px; }
.page-header { margin-bottom: 28px; } .page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; } .page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; } .page-subtitle { color: var(--text-muted); font-size: 14px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; } .back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; transition: color 0.15s; }
.back-link:hover { color: var(--accent-primary); } .back-link:hover { color: var(--accent-primary); }
.back-link .material-symbols-rounded { font-size: 18px; }
/* ============ MODE TOGGLE ============ */
.mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.mode-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 16px;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.25s ease;
color: var(--text-secondary);
box-shadow: var(--shadow-card);
}
.mode-btn:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.mode-btn.active {
border-color: var(--accent-primary);
background: var(--accent-primary-light);
color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(var(--accent-primary-rgb), 0.1);
}
.mode-btn .material-symbols-rounded {
font-size: 32px;
transition: transform 0.2s;
}
.mode-btn.active .material-symbols-rounded {
transform: scale(1.15);
}
.mode-label {
font-size: 16px;
font-weight: 700;
}
.mode-desc {
font-size: 12px;
color: var(--text-muted);
}
.mode-btn.active .mode-desc {
color: var(--accent-primary);
opacity: 0.7;
}
/* ============ FORM ============ */
.form-card { margin-bottom: 32px; } .form-card { margin-bottom: 32px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; } .quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; } .form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; } .form-row { display: flex; gap: 16px; }
.form-textarea {
resize: vertical;
min-height: 100px;
line-height: 1.6;
}
.file-upload { .file-upload {
display: flex; align-items: center; gap: 12px; padding: 20px; display: flex; align-items: center; gap: 12px; padding: 24px;
background: var(--bg-input); border: 2px dashed var(--border-color); background: var(--bg-input); border: 2px dashed var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s; border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s;
} }
...@@ -20,7 +89,117 @@ ...@@ -20,7 +89,117 @@
.file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; } .file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.file-placeholder { font-size: 14px; color: var(--text-muted); } .file-placeholder { font-size: 14px; color: var(--text-muted); }
.format-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
/* ============ AI SECTION ============ */
.ai-section {
padding: 24px;
background: rgba(var(--accent-primary-rgb), 0.04);
border: 1px solid rgba(var(--accent-primary-rgb), 0.12);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: 20px;
}
.ai-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--accent-gradient);
color: #fff;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
width: fit-content;
}
.ai-badge .material-symbols-rounded {
font-size: 16px;
}
.ai-loading-text {
font-size: 14px;
animation: pulse-text 1.5s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ============ SUBMIT ============ */
.submit-btn {
width: 100%;
margin-top: 4px;
}
/* ============ ASSIGNMENT ============ */
.assignment-box {
margin-top: 12px;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
margin-top: 8px;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.15s ease;
}
.list-item:last-child { border-bottom: none; }
.list-item:hover { background: var(--bg-hover); }
.list-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
cursor: pointer;
}
.item-details { display: flex; flex-direction: column; }
.item-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.item-sub { font-size: 12px; color: var(--text-muted); }
.empty-list {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) { @media (max-width: 768px) {
.page-container { padding: 20px 16px; } .page-container { padding: 20px 16px; }
.form-row { flex-direction: column; } .form-row { flex-direction: column; }
.mode-toggle { grid-template-columns: 1fr; }
} }
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="page-header"> <div class="page-header">
<a routerLink="/hr/quizzes" class="back-link"> <a routerLink="/admin/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes <span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a> </a>
<h1>Create New Quiz</h1> <h1>Create New Quiz</h1>
<p class="page-subtitle">Upload an Excel file with questions to create a quiz</p> <p class="page-subtitle">Choose a method to create your quiz</p>
</div>
<!-- Mode Toggle -->
<div class="mode-toggle">
<button class="mode-btn" [class.active]="mode() === 'excel'" (click)="setMode('excel')">
<span class="material-symbols-rounded">upload_file</span>
<span class="mode-label">Excel Upload</span>
<span class="mode-desc">Upload a spreadsheet</span>
</button>
<button class="mode-btn" [class.active]="mode() === 'ai'" (click)="setMode('ai')">
<span class="material-symbols-rounded">auto_awesome</span>
<span class="mode-label">AI Generate</span>
<span class="mode-desc">Let AI create questions</span>
</button>
</div> </div>
@if (error()) { @if (error()) {
...@@ -16,10 +30,14 @@ ...@@ -16,10 +30,14 @@
<div class="card card-padding form-card"> <div class="card card-padding form-card">
<form (ngSubmit)="onSubmit()" class="quiz-form"> <form (ngSubmit)="onSubmit()" class="quiz-form">
<!-- ============ EXCEL MODE ============ -->
@if (mode() === 'excel') {
<div class="form-group"> <div class="form-group">
<label class="form-label">Quiz Title *</label> <label class="form-label">Quiz Title *</label>
<input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics"> <input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Time Limit (minutes) *</label> <label class="form-label">Time Limit (minutes) *</label>
...@@ -34,12 +52,12 @@ ...@@ -34,12 +52,12 @@
</select> </select>
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Category</label> <label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" name="category" placeholder="e.g. Java, Angular, Data Structures"> <input class="form-input" [(ngModel)]="category" name="categoryExcel" placeholder="e.g. Java, Angular, Data Structures">
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Questions File (Excel) *</label> <label class="form-label">Questions File (Excel) *</label>
<div class="file-upload" (click)="fileInput.click()"> <div class="file-upload" (click)="fileInput.click()">
...@@ -51,10 +69,105 @@ ...@@ -51,10 +69,105 @@
<span class="file-placeholder">Click to select an Excel file</span> <span class="file-placeholder">Click to select an Excel file</span>
} }
</div> </div>
<p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">info</span>
Format: Question | Option1 | Option2 | Option3 | Option4 | Correct
</p>
</div>
}
<!-- ============ AI MODE ============ -->
@if (mode() === 'ai') {
<div class="ai-section animate-fade-in">
<div class="ai-badge">
<span class="material-symbols-rounded">auto_awesome</span>
AI-Powered Generation
</div>
<div class="form-group">
<label class="form-label">Describe Your Quiz *</label>
<textarea class="form-input form-textarea" [(ngModel)]="category" name="category" rows="4"
placeholder="e.g. Generate a quiz on Operating Systems covering process scheduling, memory management, and file systems. Include 10 questions that a student can answer in 15 minutes."></textarea>
<p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">lightbulb</span>
Tip: Mention the topic, number of questions, and time limit in your prompt. AI will auto-generate the quiz title, timer, and questions.
</p>
</div>
<div class="form-group">
<label class="form-label">Difficulty Level</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficultyAi">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
}
<!-- Assignment (shared) -->
<div class="form-group">
<label class="form-label">Assign To</label>
<select class="form-select" [(ngModel)]="assignmentType" name="assignmentType">
<option value="none">None (Assign Later)</option>
<option value="all">All Candidates</option>
<option value="users">Individual People</option>
<option value="groups">Specific Groups</option>
</select>
</div>
@if (assignmentType === 'users') {
<div class="form-group assignment-box">
<label class="form-label">Search Candidates</label>
<input class="form-input" type="text" [(ngModel)]="userSearchQuery" name="userSearchQuery"
(input)="onUserSearch()" placeholder="Search by name or email...">
<div class="list-container mt-2">
@for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers.includes(user._id)"
(change)="toggleUserSelection(user._id)">
<div class="item-details">
<span class="item-name">{{ user.name }}</span>
<span class="item-sub">{{ user.email }}</span>
</div>
</div>
}
@if (filteredUsers.length === 0) {
<div class="empty-list">No candidates found</div>
}
</div> </div>
<button type="submit" class="btn btn-primary btn-lg" [disabled]="loading()" style="width: 100%;"> </div>
@if (loading()) { }
@if (assignmentType === 'groups') {
<div class="form-group assignment-box">
<label class="form-label">Select Groups</label>
<div class="list-container">
@for (group of availableGroups; track group) {
<div class="list-item" (click)="toggleGroupSelection(group)">
<input type="checkbox" [checked]="selectedGroups.includes(group)"
(change)="toggleGroupSelection(group)">
<div class="item-details">
<span class="item-name">{{ group }}</span>
</div>
</div>
}
@if (availableGroups.length === 0) {
<div class="empty-list">No groups found</div>
}
</div>
</div>
}
<!-- Submit -->
<button type="submit" class="btn btn-primary btn-lg submit-btn" [disabled]="loading()">
@if (loading() && mode() === 'ai') {
<div class="spinner"></div>
<span class="ai-loading-text">AI is generating your quiz...</span>
} @else if (loading()) {
<div class="spinner"></div> Creating... <div class="spinner"></div> Creating...
} @else if (mode() === 'ai') {
<span class="material-symbols-rounded">auto_awesome</span> Generate Quiz with AI
} @else { } @else {
<span class="material-symbols-rounded">add_circle</span> Create Quiz <span class="material-symbols-rounded">add_circle</span> Create Quiz
} }
......
import { Component, signal } from '@angular/core'; import { Component, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
...@@ -12,19 +12,90 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -12,19 +12,90 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './create-quiz.css' styleUrl: './create-quiz.css'
}) })
export class HRCreateQuizComponent { export class HRCreateQuizComponent {
// Mode toggle: 'excel' or 'ai'
mode = signal<'excel' | 'ai'>('excel');
// Excel mode fields
title = ''; title = '';
timer = 30; timer = 30;
category = '';
difficulty = 'medium'; difficulty = 'medium';
category = '';
selectedFile: File | null = null; selectedFile: File | null = null;
fileName = signal(''); fileName = signal('');
// Assignment
assignmentType = 'all';
availableUsers: any[] = [];
filteredUsers: any[] = [];
availableGroups: string[] = [];
selectedUsers: string[] = [];
selectedGroups: string[] = [];
userSearchQuery = '';
// State
loading = signal(false); loading = signal(false);
success = signal(''); success = signal('');
error = signal(''); error = signal('');
constructor(private quizService: QuizService, private router: Router) {} constructor(private quizService: QuizService, private router: Router) {}
ngOnInit(): void {
this.fetchUsersAndGroups();
}
setMode(mode: 'excel' | 'ai'): void {
this.mode.set(mode);
this.error.set('');
this.success.set('');
}
fetchUsersAndGroups(): void {
this.quizService.getHRCandidates().subscribe({
next: (res) => {
this.availableUsers = res.users || [];
this.filteredUsers = [...this.availableUsers];
},
error: (err) => console.error('Failed to fetch users', err)
});
this.quizService.getHRGroups().subscribe({
next: (res) => {
this.availableGroups = res.groups || [];
},
error: (err) => console.error('Failed to fetch groups', err)
});
}
onUserSearch(): void {
if (!this.userSearchQuery.trim()) {
this.filteredUsers = [...this.availableUsers];
} else {
const q = this.userSearchQuery.toLowerCase();
this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
}
}
toggleUserSelection(userId: string): void {
const index = this.selectedUsers.indexOf(userId);
if (index > -1) {
this.selectedUsers.splice(index, 1);
} else {
this.selectedUsers.push(userId);
}
}
toggleGroupSelection(group: string): void {
const index = this.selectedGroups.indexOf(group);
if (index > -1) {
this.selectedGroups.splice(index, 1);
} else {
this.selectedGroups.push(group);
}
}
onFileSelected(event: Event): void { onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {
...@@ -34,6 +105,14 @@ export class HRCreateQuizComponent { ...@@ -34,6 +105,14 @@ export class HRCreateQuizComponent {
} }
onSubmit(): void { onSubmit(): void {
if (this.mode() === 'excel') {
this.submitExcel();
} else {
this.submitAI();
}
}
private submitExcel(): void {
if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; } if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; }
if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; } if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; }
if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; } if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; }
...@@ -46,8 +125,10 @@ export class HRCreateQuizComponent { ...@@ -46,8 +125,10 @@ export class HRCreateQuizComponent {
formData.append('title', this.title); formData.append('title', this.title);
formData.append('timer', this.timer.toString()); formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile); formData.append('questionsFile', this.selectedFile);
if (this.category) formData.append('category', this.category);
if (this.difficulty) formData.append('difficulty', this.difficulty); if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.category) formData.append('category', this.category);
this.appendAssignment(formData);
this.quizService.createHRQuiz(formData).subscribe({ this.quizService.createHRQuiz(formData).subscribe({
next: (res) => { next: (res) => {
...@@ -61,4 +142,59 @@ export class HRCreateQuizComponent { ...@@ -61,4 +142,59 @@ export class HRCreateQuizComponent {
} }
}); });
} }
private submitAI(): void {
if (!this.category.trim()) { this.error.set('Please describe your quiz topic/category'); return; }
this.loading.set(true);
this.error.set('');
this.success.set('');
const data: any = {
prompt: this.category,
difficulty: this.difficulty
};
if (this.assignmentType === 'all') {
data.assignToAll = true;
} else if (this.assignmentType === 'users') {
data.assignToAll = false;
data.assignees = this.selectedUsers;
} else if (this.assignmentType === 'groups') {
data.assignToAll = false;
data.assignedGroups = this.selectedGroups;
} else if (this.assignmentType === 'none') {
data.assignToAll = false;
data.assignees = [];
data.assignedGroups = [];
}
this.quizService.generateHRAIQuiz(data).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`AI Quiz "${res.quiz.title}" generated with ${res.quiz.totalQuestions} questions!`);
setTimeout(() => this.router.navigate(['/hr/quizzes']), 2000);
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'AI generation failed');
}
});
}
private appendAssignment(formData: FormData): void {
if (this.assignmentType === 'all') {
formData.append('assignToAll', 'true');
} else if (this.assignmentType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers));
} else if (this.assignmentType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups));
} else if (this.assignmentType === 'none') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify([]));
formData.append('assignedGroups', JSON.stringify([]));
}
}
} }
.page { padding: 32px; max-width: 1200px; margin: 0 auto; } /* Admin Dashboard */
.page-header { margin-bottom: 32px; } .page {
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; } padding: 32px;
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; } max-width: 1200px;
.loading-center { display: flex; align-items: center; justify-content: center; padding: 80px; } margin: 0 auto;
}
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { display: flex; align-items: center; gap: 16px; } .page-header {
.stat-ic { font-size: 32px; } margin-bottom: 32px;
.stat-ic.blue { color: var(--accent-primary); } }
.stat-ic.green { color: var(--success); }
.stat-ic.orange { color: var(--warning); } .page-header h1 {
.stat-body { display: flex; flex-direction: column; } font-size: 24px;
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); } font-weight: 700;
.stat-label { font-size: 13px; color: var(--text-muted); } color: var(--text-primary);
margin: 0 0 4px;
.actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } }
.action-card { display: flex; align-items: center; gap: 16px; text-decoration: none; color: inherit; }
.action-icon { font-size: 28px; color: var(--accent-primary); } .page-subtitle {
.action-info { flex: 1; } font-size: 14px;
.action-info h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 4px; } color: var(--text-muted);
.action-info p { font-size: 13px; color: var(--text-muted); margin: 0; } margin: 0;
.arrow { color: var(--text-muted); font-size: 20px; transition: transform 0.2s; } }
.action-card:hover .arrow { transform: translateX(4px); color: var(--accent-primary); }
/* Loading */
@media (max-width: 768px) { .page { padding: 20px; } .actions-grid { grid-template-columns: 1fr; } } .loading-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
gap: 16px;
}
.loading-text {
font-size: 14px;
color: var(--text-muted);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-wrap .material-symbols-rounded {
font-size: 24px;
color: #fff;
}
.stat-icon-wrap.blue { background: linear-gradient(135deg, #4f6ef7, #3b5bdb); }
.stat-icon-wrap.purple { background: linear-gradient(135deg, #7c5cfc, #6033e0); }
.stat-icon-wrap.green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.stat-icon-wrap.orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon-wrap.teal { background: linear-gradient(135deg, #14b8a6, #0d9488); }
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
/* Sections */
.section {
margin-bottom: 32px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.action-card {
display: flex;
align-items: center;
gap: 16px;
text-decoration: none;
color: inherit;
}
.action-icon {
font-size: 28px;
color: var(--accent-primary);
flex-shrink: 0;
}
.action-info {
flex: 1;
}
.action-info h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.action-info p {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.action-arrow {
color: var(--text-muted);
font-size: 20px;
transition: transform 0.2s;
}
.action-card:hover .action-arrow {
transform: translateX(4px);
color: var(--accent-primary);
}
/* Table helpers */
.user-cell {
display: flex;
flex-direction: column;
}
.user-cell-name {
font-weight: 600;
font-size: 14px;
}
.user-cell-email {
font-size: 12px;
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.page { padding: 20px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.actions-grid { grid-template-columns: 1fr; }
}
<!-- hr Dashboard -->
<div class="page animate-fade-in"> <div class="page animate-fade-in">
<div class="page-header"><h1>HR Dashboard</h1><p class="page-subtitle">Manage quizzes and review candidates</p></div> <div class="page-header">
@if (loading()) { <div class="loading-center"><div class="spinner spinner-lg"></div></div> } <div>
@else if (stats()) { <h1>Dashboard</h1>
<p class="page-subtitle">Overview of your assessment platform</p>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else if (stats()) {
<!-- Stats Cards -->
<div class="stats-grid stagger-children"> <div class="stats-grid stagger-children">
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic blue">quiz</span><div class="stat-body"><span class="stat-value">{{ stats().myQuizzes }}</span><span class="stat-label">My Quizzes</span></div></div> <div class="stat-card">
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic green">people</span><div class="stat-body"><span class="stat-value">{{ stats().totalCandidates }}</span><span class="stat-label">Candidates</span></div></div> <div class="stat-icon-wrap blue">
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic orange">assignment_turned_in</span><div class="stat-body"><span class="stat-value">{{ stats().totalSubmissions }}</span><span class="stat-label">Submissions</span></div></div> <span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div> </div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/quizzes" class="action-card card card-hover card-padding"> <a routerLink="/hr/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span><div class="action-info"><h3>My Quizzes</h3><p>View and manage your quizzes</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a> <span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding"> <a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span><div class="action-info"><h3>Create Quiz</h3><p>Upload or generate new assessments</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a> <span class="material-symbols-rounded action-icon">add_circle</span>
<a routerLink="/hr/candidates" class="action-card card card-hover card-padding"> <div class="action-info">
<span class="material-symbols-rounded action-icon">people</span><div class="action-info"><h3>Candidates</h3><p>View candidate results and history</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a> <h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div> </div>
</div>
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
'badge-success': sub.percentage >= 70,
'badge-warning': sub.percentage >= 40 && sub.percentage < 70,
'badge-danger': sub.percentage < 40
}">{{ sub.percentage }}%</span>
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
} }
</div> </div>
...@@ -2,6 +2,7 @@ import { Component, OnInit, signal } from '@angular/core'; ...@@ -2,6 +2,7 @@ import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
import { UiService } from '../../../services/ui.service';
@Component({ @Component({
selector: 'app-hr-dashboard', selector: 'app-hr-dashboard',
...@@ -12,14 +13,23 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -12,14 +13,23 @@ import { QuizService } from '../../../services/quiz.service';
}) })
export class HRDashboardComponent implements OnInit { export class HRDashboardComponent implements OnInit {
stats = signal<any>(null); stats = signal<any>(null);
recentSubmissions = signal<any[]>([]);
loading = signal(true); loading = signal(true);
constructor(private quizService: QuizService) {} constructor(private quizService: QuizService, private uiService: UiService) {}
ngOnInit(): void { ngOnInit(): void {
this.quizService.getHRStats().subscribe({ this.quizService.getHRStats().subscribe({
next: (res) => { this.stats.set(res.stats); this.loading.set(false); }, next: (res) => {
this.stats.set(res.stats);
this.recentSubmissions.set(res.recentSubmissions || []);
this.loading.set(false);
},
error: () => this.loading.set(false) error: () => this.loading.set(false)
}); });
} }
openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true);
}
} }
.page-container { padding: 32px 40px; max-width: 900px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 0; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.form-card { margin-bottom: 24px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; }
.questions-header { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 16px; }
.questions-header h2 { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.question-card { margin-bottom: 16px; display: flex; flex-direction: column; gap: 14px; }
.question-header { display: flex; align-items: center; justify-content: space-between; }
.q-number { font-size: 13px; font-weight: 700; color: var(--accent-primary); background: var(--accent-primary-light); padding: 4px 12px; border-radius: var(--radius-full); }
.options-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.option-input-wrap { display: flex; align-items: center; gap: 8px; }
.option-letter { font-size: 12px; font-weight: 700; color: var(--text-muted); width: 20px; text-align: center; flex-shrink: 0; }
.save-btn { width: 100%; margin-top: 24px; }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row, .options-grid { flex-direction: column; grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<a routerLink="/admin/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Edit Quiz</h1>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
@if (locked()) {
<div class="alert alert-warning"><span class="material-symbols-rounded">lock</span> This quiz has been attempted. Questions cannot be modified.</div>
}
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading quiz...</p></div>
} @else {
<div class="card card-padding form-card">
<div class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title</label>
<input class="form-input" [(ngModel)]="title" placeholder="Quiz title">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Timer (min)</label>
<input class="form-input" type="number" [(ngModel)]="timer" min="1">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" placeholder="e.g. Java, Angular, Data Structures">
</div>
</div>
</div>
@if (!locked()) {
<div class="questions-header">
<h2>Questions ({{ questions().length }})</h2>
<button class="btn btn-outline btn-sm" (click)="addQuestion()">
<span class="material-symbols-rounded">add</span> Add Question
</button>
</div>
@for (q of questions(); track $index; let i = $index) {
<div class="card card-padding question-card">
<div class="question-header">
<span class="q-number">Q{{ i + 1 }}</span>
<button class="btn btn-ghost btn-sm" (click)="removeQuestion(i)">
<span class="material-symbols-rounded">delete</span>
</button>
</div>
<div class="form-group">
<input class="form-input" [ngModel]="q.question" (ngModelChange)="updateQuestion(i, 'question', $event)" placeholder="Question text">
</div>
<div class="options-grid">
@for (opt of q.options; track $index; let j = $index) {
<div class="option-input-wrap">
<span class="option-letter">{{ ['A','B','C','D'][j] }}</span>
<input class="form-input" [ngModel]="opt" (ngModelChange)="updateOption(i, j, $event)" placeholder="Option {{ j+1 }}">
</div>
}
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Correct Answer</label>
<input class="form-input" [ngModel]="q.correctAnswer" (ngModelChange)="updateQuestion(i, 'correctAnswer', $event)" placeholder="Correct answer text">
</div>
<div class="form-group" style="max-width: 120px;">
<label class="form-label">Marks</label>
<input class="form-input" type="number" [ngModel]="q.marks" (ngModelChange)="updateQuestion(i, 'marks', $event)" min="1">
</div>
</div>
</div>
}
}
<button class="btn btn-primary btn-lg save-btn" (click)="onSave()" [disabled]="saving()">
@if (saving()) {
<div class="spinner"></div> Saving...
} @else {
<span class="material-symbols-rounded">save</span> Save Changes
}
</button>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-edit-quiz',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './edit-quiz.html',
styleUrl: './edit-quiz.css',
})
export class HREditQuizComponent {
quizId = '';
title = '';
timer = 30;
category = '';
difficulty = 'medium';
questions = signal<any[]>([]);
loading = signal(true);
saving = signal(false);
success = signal('');
error = signal('');
locked = signal(false);
constructor(
private route: ActivatedRoute,
private router: Router,
private quizService: QuizService
) {}
ngOnInit(): void {
this.quizId = this.route.snapshot.params['quizId'];
this.loadQuiz();
}
loadQuiz(): void {
this.quizService.getHRQuiz(this.quizId).subscribe({
next: (res) => {
const q = res.quiz;
this.title = q.title;
this.timer = q.timer;
this.category = q.category || '';
this.difficulty = q.difficulty || 'medium';
this.questions.set(q.questions || []);
this.locked.set(res.hasAttempts || false);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to load quiz');
this.loading.set(false);
}
});
}
onSave(): void {
if (!this.title.trim()) {
this.error.set('Title is required');
return;
}
this.saving.set(true);
this.error.set('');
// 🔥 Format questions correctly
const formattedQuestions = this.questions().map(q => {
const options = q.options;
const correctIndex = options.findIndex(
(opt: string) => opt == q.correctAnswer
);
return {
question: q.question,
options,
correctAnswers: [correctIndex.toString()],
type: 'single'
};
}); // ✅ closes map()
// 🔥 Final data
const data = {
title: this.title,
timer: this.timer,
category: this.category,
difficulty: this.difficulty,
questions: formattedQuestions
}; // ✅ closes data object
this.quizService.updateHRQuiz(this.quizId, data).subscribe({
next: () => {
this.saving.set(false);
this.success.set('Quiz updated successfully!');
setTimeout(() => this.router.navigate(['/hr/quizzes']), 1200);
},
error: (err) => {
this.saving.set(false);
this.error.set(err.error?.message || 'Failed to update quiz');
}
}); // ✅ closes subscribe
} // ✅ closes function
updateQuestion(index: number, field: string, value: any): void {
const q = [...this.questions()];
q[index] = { ...q[index], [field]: value };
this.questions.set(q);
}
updateOption(qIndex: number, optIndex: number, value: string): void {
const q = [...this.questions()];
const opts = [...(q[qIndex].options || [])];
opts[optIndex] = value;
q[qIndex] = { ...q[qIndex], options: opts };
this.questions.set(q);
}
removeQuestion(index: number): void {
const q = this.questions().filter((_, i) => i !== index);
this.questions.set(q);
}
addQuestion(): void {
this.questions.set([...this.questions(), {
question: '', options: ['', '', '', ''], correctAnswer: '', marks: 1
}]);
}
}
.dashboard-layout { display: flex; min-height: 100vh; background: #0f1117; }
.sidebar { width: 260px; background: rgba(255,255,255,0.03); border-right: 1px solid rgba(255,255,255,0.06); display: flex; flex-direction: column; padding: 24px 16px; position: fixed; top: 0; left: 0; bottom: 0; z-index: 10; }
.sidebar-header { display: flex; align-items: center; gap: 10px; padding: 0 8px; margin-bottom: 32px; flex-wrap: wrap; }
.sidebar-header .logo-icon { font-size: 28px; }
.sidebar-header h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0; }
.role-badge { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; text-transform: uppercase; }
.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; color: rgba(255,255,255,0.6); text-decoration: none; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: rgba(102,126,234,0.15); color: #667eea; }
.nav-icon { font-size: 18px; }
.sidebar-footer { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px; }
.user-info { display: flex; align-items: center; gap: 12px; padding: 8px; margin-bottom: 12px; }
.user-avatar { width: 38px; height: 38px; border-radius: 10px; background: linear-gradient(135deg, #667eea, #764ba2); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 16px; }
.user-details { display: flex; flex-direction: column; }
.user-name { color: #fff; font-size: 13px; font-weight: 600; }
.user-email { color: rgba(255,255,255,0.4); font-size: 11px; }
.logout-btn { width: 100%; padding: 10px; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); border-radius: 10px; color: #f87171; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; }
.logout-btn:hover { background: rgba(239,68,68,0.2); }
.main-content { flex: 1; margin-left: 260px; padding: 40px; }
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 28px; font-weight: 700; color: #fff; margin: 0 0 8px; }
.page-header p { color: rgba(255,255,255,0.5); font-size: 15px; margin: 0; }
.alert { padding: 14px 20px; border-radius: 12px; margin-bottom: 20px; font-size: 14px; font-weight: 500; animation: fadeIn 0.3s ease; }
.alert-success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #f87171; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.create-section { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 32px; margin-bottom: 40px; }
.quiz-form { display: flex; flex-direction: column; gap: 24px; }
.form-row { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
.form-group label { display: block; color: rgba(255,255,255,0.7); font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.form-group input {
width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px; color: #fff; font-size: 15px; outline: none; transition: all 0.3s; font-family: inherit; box-sizing: border-box;
}
.form-group input::placeholder { color: rgba(255,255,255,0.3); }
.form-group input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.file-upload {
border: 2px dashed rgba(255,255,255,0.15); border-radius: 16px; padding: 32px;
text-align: center; cursor: pointer; transition: all 0.3s; display: flex; flex-direction: column;
align-items: center; gap: 8px;
}
.file-upload:hover { border-color: #667eea; background: rgba(102,126,234,0.05); }
.upload-icon { font-size: 36px; }
.upload-text { color: rgba(255,255,255,0.7); font-size: 15px; font-weight: 500; }
.upload-hint { color: rgba(255,255,255,0.35); font-size: 12px; }
.file-icon { font-size: 28px; }
.file-name { color: #fff; font-size: 15px; font-weight: 600; }
.file-change { color: #667eea; font-size: 13px; font-weight: 500; }
.btn { padding: 14px 24px; border: none; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; box-shadow: 0 4px 15px rgba(102,126,234,0.3); }
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 25px rgba(102,126,234,0.4); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.section-title { color: #fff; font-size: 18px; font-weight: 600; margin: 0 0 20px; }
.loading-state { display: flex; justify-content: center; padding: 40px; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1); border-top-color: #667eea; border-radius: 50%; animation: spin 0.8s linear infinite; }
.empty-state { text-align: center; padding: 60px 0; }
.empty-icon { font-size: 48px; display: block; margin-bottom: 16px; }
.empty-state h3 { color: #fff; font-size: 18px; margin: 0 0 8px; }
.empty-state p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 0; }
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.quiz-card {
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px; padding: 20px; transition: all 0.2s;
}
.quiz-card:hover { border-color: rgba(255,255,255,0.15); }
.quiz-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.quiz-card-header h4 { color: #fff; font-size: 16px; font-weight: 600; margin: 0; }
.delete-btn { background: none; border: none; font-size: 16px; cursor: pointer; padding: 4px; opacity: 0.5; transition: opacity 0.2s; }
.delete-btn:hover { opacity: 1; }
.quiz-meta { display: flex; gap: 16px; color: rgba(255,255,255,0.5); font-size: 13px; margin-bottom: 8px; }
.quiz-date { color: rgba(255,255,255,0.3); font-size: 12px; }
@media (max-width: 768px) {
.sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); }
.main-content { margin-left: 0; padding: 24px; }
.dashboard-layout { flex-direction: column; }
.form-row { grid-template-columns: 1fr; }
}
.form-select {
width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px; color: #fff; font-size: 15px; outline: none; transition: all 0.3s; font-family: inherit; box-sizing: border-box;
}
.form-select:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.assignment-box {
margin-top: 12px;
padding: 16px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
}
.list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
background: rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.mt-2 { margin-top: 8px; }
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: background 0.15s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: rgba(255,255,255,0.05);
}
.list-item input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.item-details {
display: flex;
flex-direction: column;
}
.item-name {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.item-sub {
font-size: 12px;
color: rgba(255,255,255,0.5);
}
.empty-state {
padding: 20px;
text-align: center;
color: rgba(255,255,255,0.5);
font-size: 13px;
font-style: italic;
}
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-header">
<span class="logo-icon">📝</span>
<h2>QuizMaster</h2><span class="role-badge">Admin</span>
</div>
<nav class="sidebar-nav">
<a routerLink="/admin/dashboard" class="nav-item"><span class="nav-icon">🏠</span><span>Dashboard</span></a>
<a routerLink="/admin/users" class="nav-item"><span class="nav-icon">👥</span><span>Users</span></a>
<a routerLink="/admin/generate-quiz" class="nav-item active"><span class="nav-icon"></span><span>Generate
Quiz</span></a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">{{ authService.currentUser()?.name?.charAt(0) || 'A' }}</div>
<div class="user-details"><span class="user-name">{{ authService.currentUser()?.name }}</span><span
class="user-email">{{ authService.currentUser()?.email }}</span></div>
</div>
<button class="logout-btn" (click)="logout()"><span>🚪</span> Logout</button>
</div>
</aside>
<main class="main-content">
<div class="page-header">
<h1>Generate Quiz</h1>
<p>Create a new quiz by uploading questions from an Excel file</p>
</div>
@if (success()) {
<div class="alert alert-success">✅ {{ success() }}</div>
}
@if (error()) {
<div class="alert alert-error">⚠️ {{ error() }}</div>
}
<div class="create-section">
<form (ngSubmit)="onSubmit()" class="quiz-form">
<div class="form-row">
<div class="form-group">
<label for="title">Quiz Name</label>
<input type="text" id="title" [(ngModel)]="title" name="title"
placeholder="e.g., JavaScript Fundamentals" />
</div>
<div class="form-group">
<label for="timer">Timer (minutes)</label>
<input type="number" id="timer" [(ngModel)]="timer" name="timer" min="1" placeholder="30" />
</div>
</div>
<div class="form-group">
<label>Questions File (.xlsx)</label>
<div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden />
@if (fileName()) {
<span class="file-icon">📄</span>
<span class="file-name">{{ fileName() }}</span>
<span class="file-change">Change</span>
} @else {
<span class="upload-icon">📤</span>
<span class="upload-text">Click to upload Excel file</span>
<span class="upload-hint">Format: Question | Option1 | Option2 | Option3 | Option4 | Correct</span>
}
</div>
</div>
<div class="form-group">
<label>Assign Quiz To</label>
<select class="form-select" [ngModel]="assignmentType()" (ngModelChange)="assignmentType.set($event)" name="assignmentType">
<option value="all">All Candidates</option>
<option value="users">Individual People</option>
<option value="groups">Specific Groups</option>
</select>
</div>
@if (assignmentType() === 'users') {
<div class="form-group assignment-box">
<label>Search Candidates</label>
<input class="form-input" type="text" [ngModel]="userSearchQuery()" (ngModelChange)="userSearchQuery.set($event); onUserSearch()" name="userSearchQuery" placeholder="Search by name or email...">
<div class="list-container mt-2">
@for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers().includes(user._id)"
(change)="toggleUserSelection(user._id)">
<div class="item-details">
<span class="item-name">{{ user.name }}</span>
<span class="item-sub">{{ user.email }}</span>
</div>
</div>
}
@if (filteredUsers.length === 0) {
<div class="empty-state">No candidates found</div>
}
</div>
</div>
}
@if (assignmentType() === 'groups') {
<div class="form-group assignment-box">
<label>Select Groups</label>
<div class="list-container mt-2">
@for (group of groups(); track group) {
<div class="list-item" (click)="onGroupToggle(group, {target: {checked: !selectedGroups().includes(group)}})">
<input type="checkbox" [checked]="selectedGroups().includes(group)"
(change)="onGroupToggle(group, $event)">
<div class="item-details">
<span class="item-name">{{ group }}</span>
</div>
</div>
}
@if (groups().length === 0) {
<div class="empty-state">No groups found</div>
}
</div>
</div>
}
<button mat-raised-button color="primary" type="submit" class="btn btn-primary" [disabled]="loading()">
@if (loading()) {
<span class="spinner"></span> Creating Quiz...
} @else {
🚀 Create Quiz
}
</button>
</form>
</div>
<!-- Existing Quizzes -->
<h2 class="section-title">Existing Quizzes</h2>
@if (loadingQuizzes()) {
<div class="loading-state">
<div class="loader"></div>
</div>
} @else if (quizzes().length === 0) {
<div class="empty-state">
<span class="empty-icon">📋</span>
<h3>No quizzes yet</h3>
<p>Create your first quiz above</p>
</div>
} @else {
<div class="quiz-grid">
@for (quiz of quizzes(); track quiz._id) {
<div class="quiz-card">
<div class="quiz-card-header">
<h4>{{ quiz.title }}</h4>
<button class="delete-btn" (click)="deleteQuiz(quiz._id)">🗑️</button>
</div>
<div class="quiz-meta">
<span>⏱️ {{ quiz.timer }} min</span>
<span>📝 {{ quiz.totalQuestions }} questions</span>
</div>
<span class="quiz-date">Created {{ quiz.createdAt | date:'mediumDate' }}</span>
</div>
}
</div>
}
</main>
</div>
\ No newline at end of file
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-generate-quiz',
imports: [CommonModule, FormsModule, RouterLink, MatButtonModule],
templateUrl: './generate-quiz.html',
styleUrl: './generate-quiz.css',
})
export class GenerateQuiz {
title = '';
timer = 30;
selectedFile: File | null = null;
fileName = signal<string>('');
groups = signal<any[]>([]); // Actually used for groups now, let's keep it as string[] if the backend returns strings
availableUsers: any[] = [];
filteredUsers: any[] = [];
assignmentType = signal<string>('all'); // 'all', 'users', 'groups'
selectedUsers = signal<string[]>([]);
selectedGroups = signal<string[]>([]);
userSearchQuery = signal<string>('');
loading = signal<boolean>(false);
success = signal<string>('');
error = signal<string>('');
quizzes = signal<any[]>([]);
loadingQuizzes = signal<boolean>(true);
constructor(public authService: AuthService, private quizService: QuizService) { }
ngOnInit(): void {
this.loadQuizzes();
this.fetchUsersAndGroups();
}
fetchUsersAndGroups(): void {
this.quizService.getHRGroups().subscribe({
next: (res) => {
this.groups.set(res.groups || []);
},
error: () => console.log("Failed to load groups")
});
this.quizService.getHRCandidates().subscribe({
next: (res) => {
this.availableUsers = res.users || [];
this.filteredUsers = [...this.availableUsers];
},
error: () => console.log("Failed to load users")
});
}
onUserSearch(): void {
const q = this.userSearchQuery().toLowerCase().trim();
if (!q) {
this.filteredUsers = [...this.availableUsers];
} else {
this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
}
}
toggleUserSelection(userId: string): void {
const selected = this.selectedUsers();
if (selected.includes(userId)) {
this.selectedUsers.set(selected.filter(id => id !== userId));
} else {
this.selectedUsers.set([...selected, userId]);
}
}
loadQuizzes(): void {
this.quizService.getHRQuizzes().subscribe({
next: (res) => {
this.quizzes.set(res.quizzes);
this.loadingQuizzes.set(false);
},
error: () => this.loadingQuizzes.set(false)
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0];
this.fileName.set(this.selectedFile.name);
}
}
onSubmit(): void {
if (!this.title.trim()) {
this.error.set('Please enter a quiz title');
return;
}
if (!this.timer || this.timer < 1) {
this.error.set('Timer must be at least 1 minute');
return;
}
if (!this.selectedFile) {
this.error.set('Please upload an Excel file');
return;
}
this.loading.set(true);
this.error.set('');
this.success.set('');
const formData = new FormData();
formData.append('title', this.title);
formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile);
const aType = this.assignmentType();
if (aType === 'all') {
formData.append('assignToAll', 'true');
} else if (aType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers()));
} else if (aType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups()));
}
this.quizService.createHRQuiz(formData).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`Quiz "${res.quiz.title}" created with ${res.quiz.totalQuestions} questions!`);
this.title = '';
this.timer = 30;
this.selectedFile = null;
this.fileName.set('');
this.loadQuizzes();
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'Failed to create quiz');
}
});
}
deleteQuiz(quizId: string): void {
if (confirm('Are you sure you want to delete this quiz?')) {
this.quizService.deleteHRQuiz(quizId).subscribe({
next: () => this.loadQuizzes(),
error: (err) => this.error.set(err.error?.message || 'Failed to delete quiz')
});
}
}
onGroupToggle(groupName: string, event: any): void {
const selected = this.selectedGroups();
if (event.target.checked) {
this.selectedGroups.set([...selected, groupName]);
} else {
this.selectedGroups.set(selected.filter(g => g !== groupName));
}
}
logout(): void {
this.authService.logout();
}
}
.page-container { max-width: 1400px; padding: 32px 40px; margin: 0 auto; height: calc(100vh - 64px); display: flex; box-sizing: border-box; }
.split-view { gap: 32px; align-items: stretch; }
.main-workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-y: auto; padding-right: 16px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 8px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 0; }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; }
.card { background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border-color); }
.card-padding { padding: 24px; }
.group-form { display: flex; flex-direction: column; }
.row-align { display: flex; gap: 16px; align-items: stretch; flex-wrap: wrap; }
.input-container { flex: 1; position: relative; display: flex; align-items: center; min-width: 220px; }
.input-container .block-icon { position: absolute; left: 16px; color: var(--text-muted); font-size: 20px; }
.form-input { width: 100%; padding: 14px 16px 14px 44px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 12px; color: var(--text-primary); outline: none; transition: all 0.3s; font-size: 15px; box-sizing: border-box; }
.form-input:focus { border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.form-input-small { padding: 10px 14px; font-size: 14px; border-radius: 8px; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 14px 24px; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: all 0.3s; font-size: 15px; font-family: inherit; }
.btn-primary { background: var(--accent-gradient); color: #fff; box-shadow: 0 4px 15px rgba(102,126,234,0.3); }
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102,126,234,0.4); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.action-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; border-radius: 6px; padding: 6px; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s; }
.action-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.text-danger:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
.text-success:hover { color: #22c55e; background: rgba(34, 197, 94, 0.1); }
/* DRAG AND DROP GROUPS */
.groups-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 24px; }
.group-lane { display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px; overflow: hidden; min-height: 80px; transition: all 0.25s ease-in-out; cursor: pointer; }
.group-lane:hover { border-color: rgba(102,126,234,0.4); transform: translateY(-2px); box-shadow: var(--shadow-md); }
.group-lane.cdk-drop-list-receiving { border-color: var(--accent-primary); background: rgba(102,126,234,0.05); box-shadow: 0 0 0 2px rgba(102,126,234,0.2); transform: scale(1.02); z-index: 10; }
.group-lane-header { padding: 16px 20px; background: rgba(102,126,234,0.05); border-bottom: 1px solid transparent; display: flex; justify-content: space-between; align-items: center; transition: border-bottom 0.2s; pointer-events: none; }
.group-lane:has(.group-lane-body) .group-lane-header { border-bottom: 1px solid var(--border-color); }
.group-actions { pointer-events: auto; }
.group-lane-body { cursor: default; }
.group-header-info { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.lane-title { display: flex; align-items: center; gap: 8px; font-size: 16px; margin: 0; color: var(--text-primary); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lane-title .material-symbols-rounded { font-size: 18px; color: var(--accent-primary); }
.member-count { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.group-actions { display: flex; gap: 4px; }
.group-lane-body { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 10px; background: rgba(0,0,0,0.01); min-height: 100px; }
.empty-body { justify-content: center; align-items: center; }
.drop-placeholder { text-align: center; font-size: 13px; color: var(--text-muted); font-style: italic; opacity: 0.6; padding: 30px; border: 1px dashed var(--border-color); border-radius: 12px; width: 100%; box-sizing: border-box; }
.student-pill { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--border-color); border-radius: 10px; cursor: grab; box-shadow: 0 2px 5px rgba(0,0,0,0.02); transition: transform 0.2s, box-shadow 0.2s; }
.student-pill:active { cursor: grabbing; }
.pill-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, rgba(102,126,234,0.1) 0%, rgba(102,126,234,0.2) 100%); color: var(--accent-primary); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; flex-shrink: 0; }
.pill-info { flex: 1; min-width: 0; }
.pill-name { display: block; font-size: 14px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* UNASSIGNED SIDEBAR */
.unassigned-sidebar { width: 340px; display: flex; flex-direction: column; flex-shrink: 0; height: 100%; overflow: hidden; background: var(--surface); }
.sidebar-header { padding: 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; background: rgba(102,126,234,0.05); }
.sidebar-title { display: flex; align-items: center; gap: 10px; }
.sidebar-title h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-primary); }
.sidebar-title .material-symbols-rounded { color: var(--text-muted); font-size: 20px; }
.sidebar-badge { background: var(--accent-primary); color: white; padding: 2px 8px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.unassigned-list { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.empty-unassigned { text-align: center; padding: 40px 20px; color: var(--text-muted); display: flex; flex-direction: column; align-items: center; gap: 12px; opacity: 0.7; }
.empty-unassigned .material-symbols-rounded { font-size: 40px; color: #22c55e; }
.unassigned-card { display: flex; align-items: center; gap: 16px; padding: 14px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; cursor: grab; transition: border-color 0.2s, box-shadow 0.2s; position: relative; }
.unassigned-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 12px rgba(102,126,234,0.08); transform: translateY(-2px); }
.unassigned-card:active { cursor: grabbing; }
.unassigned-avatar { width: 44px; height: 44px; border-radius: 50%; background: var(--bg-input); color: var(--text-primary); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; flex-shrink: 0; }
.unassigned-details { flex: 1; min-width: 0; }
.unassigned-details h4 { margin: 0 0 4px 0; font-size: 15px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.unassigned-details p { margin: 0; font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.drag-handle { color: var(--text-muted); cursor: grab; opacity: 0.5; font-size: 20px; }
/* CDK DRAG AND DROP PREVIEWS */
.cdk-drag-preview { box-shadow: 0 10px 30px rgba(0,0,0,0.15); border-radius: 12px; opacity: 0.95; z-index: 1000 !important; cursor: grabbing !important; }
.cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.custom-drag-placeholder { opacity: 0.3; background: rgba(102,126,234,0.1); border: 2px dashed var(--accent-primary); border-radius: 12px; min-height: 60px; transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.unassigned-list.cdk-drop-list-dragging .unassigned-card:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.group-lane-body.cdk-drop-list-dragging .student-pill:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.alert { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-radius: 12px; margin-bottom: 24px; font-size: 14px; font-weight: 500; }
.alert-success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #22c55e; }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(102,126,234,0.2); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
.loading-state { display: flex; justify-content: center; padding: 40px; }
@keyframes spin { 100% { transform: rotate(360deg); } }
@media (max-width: 900px) {
.split-view { flex-direction: column; }
.unassigned-sidebar { width: 100%; height: 400px; }
.page-container { height: auto; padding: 20px 16px; }
}
<div class="page-container animate-fade-in split-view" cdkDropListGroup>
<div class="main-workspace">
<div class="page-header">
<h1>Manage Groups</h1>
<p class="page-subtitle">Create organizational groups and effortlessly drag-and-drop candidates into them.</p>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
<!-- Create Group Area -->
<div class="card card-padding form-card" style="margin-bottom: 30px;">
<h2 class="section-title" style="margin-top: 0; margin-bottom: 16px;">Create New Group</h2>
<form (ngSubmit)="createGroup()" class="group-form">
<div class="form-group row-align">
<div class="input-container">
<span class="material-symbols-rounded block-icon">group_add</span>
<input class="form-input" [ngModel]="newGroupName()" (ngModelChange)="newGroupName.set($event)" name="groupName" placeholder="e.g., Summer Interns 2026">
</div>
<button type="submit" class="btn btn-primary" [disabled]="loading() || !newGroupName().trim()">
@if (loading()) { <span class="spinner"></span> } @else { <span class="material-symbols-rounded">add</span> Add Group }
</button>
</div>
</form>
</div>
<h2 class="section-title">Your Groups</h2>
@if (loadingGroups()) {
<div class="loading-state"><div class="loader"></div></div>
} @else if (groups().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">group_off</span>
<h3>No groups available</h3>
<p>Create your first grouping tier up above</p>
</div>
} @else {
<!-- Outer wrapper handles the drag contexts collectively -->
<div class="groups-grid">
@for (group of groups(); track group) {
<div class="group-lane card" cdkDropList [cdkDropListData]="group" (cdkDropListDropped)="onDrop($event)" (click)="toggleGroupExpansion(group)">
<div class="group-lane-header">
<div class="group-header-info">
@if (editingGroup() === group) {
<input type="text" class="form-input form-input-small" [ngModel]="editName()" (ngModelChange)="editName.set($event)" autofocus>
} @else {
<h4 class="lane-title"><span class="material-symbols-rounded">workspaces</span> {{ group }}</h4>
<span class="member-count">{{ getStudentsInGroup(group).length }} Members</span>
}
</div>
<div class="group-actions" (click)="$event.stopPropagation()">
@if (editingGroup() === group) {
<button class="action-btn text-success" (click)="saveEdit(group)" title="Save"><span class="material-symbols-rounded">check</span></button>
<button class="action-btn text-danger" (click)="cancelEdit()" title="Cancel"><span class="material-symbols-rounded">close</span></button>
} @else {
<button class="action-btn" (click)="startEdit(group)" title="Edit"><span class="material-symbols-rounded">edit</span></button>
<button class="action-btn text-danger" (click)="deleteGroup(group)" title="Delete"><span class="material-symbols-rounded">delete</span></button>
}
</div>
</div>
<!-- Drop Zone Body (Displayed on Expansion) -->
@if (expandedGroup() === group) {
<div class="group-lane-body" [class.empty-body]="getStudentsInGroup(group).length === 0" (click)="$event.stopPropagation()">
@if (getStudentsInGroup(group).length === 0) {
<div class="drop-placeholder">No candidates found in {{group}}</div>
}
@for (student of getStudentsInGroup(group); track student._id) {
<div class="student-pill card" cdkDrag [cdkDragData]="student">
<div class="pill-avatar">{{ student.name.charAt(0).toUpperCase() }}</div>
<div class="pill-info">
<span class="pill-name">{{ student.name }}</span>
</div>
<button class="action-btn text-danger" title="Remove" (click)="removeFromGroup(student)">
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
</button>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
}
</div>
}
</div>
}
</div>
<!-- Sidebar: Unassigned Candidates -->
<aside class="unassigned-sidebar card">
<div class="sidebar-header">
<div class="sidebar-title">
<span class="material-symbols-rounded">person_search</span>
<h2>Unassigned Candidates</h2>
</div>
<div class="sidebar-badge">{{ unassignedStudents().length }}</div>
</div>
<div class="unassigned-list" cdkDropList [cdkDropListData]="'General'" (cdkDropListDropped)="onDrop($event)">
@if (unassignedStudents().length === 0) {
<div class="empty-unassigned">
<span class="material-symbols-rounded">done_all</span>
<p>All candidates are matched to groups!</p>
</div>
}
@for (student of unassignedStudents(); track student._id) {
<div class="unassigned-card" cdkDrag [cdkDragData]="student">
<div class="unassigned-avatar">{{ student.name.charAt(0).toUpperCase() }}</div>
<div class="unassigned-details">
<h4>{{ student.name }}</h4>
<p>{{ student.email }}</p>
</div>
<span class="material-symbols-rounded drag-handle">drag_indicator</span>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
</aside>
</div>
import { Component, signal, OnInit, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-manage-groups',
imports: [CommonModule, FormsModule, RouterLink, DragDropModule],
templateUrl: './manage-groups.html',
styleUrl: './manage-groups.css',
})
export class HRManageGroupsComponent {
groups = signal<string[]>([]);
allStudents = signal<any[]>([]);
newGroupName = signal<string>('');
loading = signal<boolean>(false);
error = signal<string>('');
success = signal<string>('');
loadingGroups = signal<boolean>(true);
editingGroup = signal<string | null>(null);
editName = signal<string>('');
expandedGroup = signal<string | null>(null);
unassignedStudents = computed(() => {
return this.allStudents().filter(s => !s.group || s.group === 'General');
});
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.loadGroups();
this.loadStudents();
}
loadGroups(): void {
this.loadingGroups.set(true);
this.quizService.getGroups().subscribe({
next: (res) => {
this.groups.set(res.groups || []);
this.loadingGroups.set(false);
},
error: (err) => {
this.error.set('Failed to load groups');
this.loadingGroups.set(false);
}
});
}
loadStudents(): void {
this.quizService.getUsers('candidate').subscribe({
next: (res) => {
// Since getUsers('candidate') might not exist perfectly on HR route if we don't change HR route, wait, HR route has getHRCandidates
// Let's rely on standard getUsers which points to getBaseUrl()/users depending on role if properly set. Actually quiz.service.ts uses adminUrl directly for getUsers. Let's see. I will dynamically fetch.
this.allStudents.set(res.users ? res.users.filter((u:any) => u.role === 'candidate') : (res.candidates || []));
},
error: () => {}
});
}
getStudentsInGroup(groupName: string): any[] {
return this.allStudents().filter(s => s.group === groupName);
}
toggleGroupExpansion(groupName: string, event?: Event): void {
if (event) {
event.stopPropagation();
}
if (this.expandedGroup() === groupName) {
this.expandedGroup.set(null);
} else {
this.expandedGroup.set(groupName);
}
}
createGroup(): void {
if (!this.newGroupName().trim()) {
this.error.set('Group name cannot be blank');
return;
}
this.loading.set(true);
this.error.set('');
this.success.set('');
this.quizService.createGroup(this.newGroupName()).subscribe({
next: () => {
this.success.set('Group created successfully!');
this.loading.set(false);
this.newGroupName.set('');
this.loadGroups();
},
error: (err: any) => {
this.error.set(err.error?.message || 'Failed to create group');
this.loading.set(false);
}
});
}
startEdit(name: string): void {
this.editingGroup.set(name);
this.editName.set(name);
}
cancelEdit(): void {
this.editingGroup.set(null);
}
saveEdit(oldName: string): void {
const trimmed = this.editName().trim();
if (!trimmed || trimmed === oldName) {
this.cancelEdit();
return;
}
this.error.set('');
this.success.set('');
this.quizService.updateGroup(oldName, trimmed).subscribe({
next: () => {
this.success.set('Group updated successfully!');
this.cancelEdit();
this.loadGroups();
// Update local students cache
this.allStudents.update(students =>
students.map(s => s.group === oldName ? { ...s, group: trimmed } : s)
);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to update group');
}
});
}
deleteGroup(name: string): void {
if (confirm(`Are you sure you want to delete the group "${name}"?\nUsers mapped to this group will moved to Unassigned.`)) {
this.error.set('');
this.success.set('');
this.quizService.deleteGroup(name).subscribe({
next: () => {
this.success.set('Group deleted successfully!');
this.loadGroups();
// Move students to General
this.allStudents.update(students =>
students.map(s => s.group === name ? { ...s, group: 'General' } : s)
);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to delete group');
}
});
}
}
onDrop(event: CdkDragDrop<string>) {
if (event.previousContainer === event.container) {
return; // No change
}
const student = event.item.data;
const previousGroup = event.previousContainer.data;
const newGroup = event.container.data;
this.allStudents.update(students =>
students.map(s => s._id === student._id ? { ...s, group: newGroup } : s)
);
this.quizService.assignUserGroup(student._id, newGroup).subscribe({
next: () => {
// Success
this.success.set(``);
},
error: () => {
this.error.set('Failed to assign user to group');
this.loadStudents();
}
});
}
removeFromGroup(student: any) {
this.allStudents.update(students =>
students.map(s => s._id === student._id ? { ...s, group: 'General' } : s)
);
this.quizService.assignUserGroup(student._id, 'General').subscribe({
next: () => {},
error: () => this.loadStudents()
});
}
}
...@@ -4,4 +4,46 @@ ...@@ -4,4 +4,46 @@
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; } .page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; } .loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); } .loading-center p { color: var(--text-muted); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
.quiz-card { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.quiz-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.quiz-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; line-height: 1.4; }
.quiz-meta { display: flex; flex-wrap: wrap; gap: 16px; }
.meta-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.quiz-topic { font-size: 13px; color: var(--text-muted); background: var(--bg-tertiary); padding: 6px 12px; border-radius: var(--radius-sm); }
.quiz-card-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border-subtle); }
.btn-primary:hover {
color: #fff; /* force text to stay white */
}
.btn-primary .material-symbols-rounded {
color: inherit;
}
.btn-primary:hover .material-symbols-rounded {
color: #fff;
}
.attempted-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.attempted-badge .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.quiz-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>My Quizzes</h1> <h1>Quizzes</h1>
<p class="page-subtitle">Manage quizzes you've created</p> <p class="page-subtitle">Manage all quizzes in the system</p>
</div> </div>
<a routerLink="/hr/create-quiz" class="btn btn-primary"> <a routerLink="/admin/create-quiz" class="btn btn-primary">
<span class="material-symbols-rounded">add</span> Create Quiz <span class="material-symbols-rounded">add</span> Create Quiz
</a> </a>
</div> </div>
@if (error()) { @if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div> <div class="alert alert-error">
<span class="material-symbols-rounded">error</span> {{ error() }}
</div>
} }
@if (loading()) { @if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading quizzes...</p></div> <div class="loading-center">
<div class="spinner spinner-lg"></div>
<p>Loading quizzes...</p>
</div>
} @else if (quizzes().length === 0) { } @else if (quizzes().length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="material-symbols-rounded">quiz</span> <span class="material-symbols-rounded">quiz</span>
<h3>No quizzes yet</h3> <h3>No quizzes yet</h3>
<p>Create your first quiz to get started.</p> <p>Create your first quiz to get started.</p>
<a routerLink="/admin/create-quiz" class="btn btn-primary" style="margin-top: 16px;">Create Quiz</a>
</div> </div>
} @else { } @else {
<div class="table-container"> <div class="quiz-grid stagger-children">
<table>
<thead>
<tr>
<th>Title</th>
<th>Questions</th>
<th>Timer</th>
<th>Category</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (quiz of quizzes(); track quiz._id) { @for (quiz of quizzes(); track quiz._id) {
<tr> <div class="card card-hover quiz-card">
<td><strong>{{ quiz.title }}</strong></td> <div class="quiz-card-header">
<td>{{ quiz.totalQuestions }}</td> <h3 class="quiz-title">{{ quiz.title }}</h3>
<td>{{ quiz.timer }} min</td> <span class="badge" [ngClass]="getDifficultyClass(quiz.difficulty)">
<td>{{ quiz.category || '—' }}</td> {{ quiz.difficulty || 'General' }}
<td>{{ quiz.createdAt | date:'mediumDate' }}</td> </span>
<td> </div>
<div class="quiz-meta">
<div class="meta-item">
<span class="material-symbols-rounded">help</span>
{{ quiz.totalQuestions }} questions
</div>
<div class="meta-item">
<span class="material-symbols-rounded">timer</span>
{{ quiz.timer }} min
</div>
<div class="meta-item">
<span class="material-symbols-rounded">category</span>
{{ quiz.category || 'Uncategorized' }}
</div>
</div>
<div class="quiz-card-actions">
<a [routerLink]="['/admin/quiz', quiz._id, 'assign']" class="btn btn-primary btn-sm">
<span class="material-symbols-rounded">person_add</span> Assign
</a>
@if (quiz.attemptCount > 0) {
<span class="attempted-badge">
<span class="material-symbols-rounded">lock</span>
{{ quiz.attemptCount }} attempt{{ quiz.attemptCount > 1 ? 's' : '' }}
</span>
} @else {
<a [routerLink]="['/admin/quiz', quiz._id, 'edit']" class="btn btn-outline btn-sm">
<span class="material-symbols-rounded">edit</span> Edit
</a>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)"> <button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)">
<span class="material-symbols-rounded">delete</span> <span class="material-symbols-rounded">delete</span> Delete
</button> </button>
</td>
</tr>
} }
</tbody> </div>
</table> </div>
}
</div> </div>
} }
</div> </div>
...@@ -11,7 +11,7 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -11,7 +11,7 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './quizzes.css' styleUrl: './quizzes.css'
}) })
export class HRQuizzesComponent implements OnInit { export class HRQuizzesComponent implements OnInit {
quizzes = signal<any[]>([]); quizzes = signal<any[]>([]);
loading = signal(true); loading = signal(true);
error = signal(''); error = signal('');
...@@ -22,6 +22,7 @@ export class HRQuizzesComponent implements OnInit { ...@@ -22,6 +22,7 @@ export class HRQuizzesComponent implements OnInit {
} }
loadQuizzes(): void { loadQuizzes(): void {
this.loading.set(true);
this.quizService.getHRQuizzes().subscribe({ this.quizService.getHRQuizzes().subscribe({
next: (res) => { this.quizzes.set(res.quizzes); this.loading.set(false); }, next: (res) => { this.quizzes.set(res.quizzes); this.loading.set(false); },
error: () => this.loading.set(false) error: () => this.loading.set(false)
...@@ -35,4 +36,13 @@ export class HRQuizzesComponent implements OnInit { ...@@ -35,4 +36,13 @@ export class HRQuizzesComponent implements OnInit {
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz') error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz')
}); });
} }
getDifficultyClass(d: string): string {
switch (d?.toLowerCase()) {
case 'easy': return 'badge-success';
case 'medium': return 'badge-warning';
case 'hard': return 'badge-danger';
default: return 'badge-primary';
}
}
} }
.page-container { padding: 32px 40px; max-width: 900px; } .page-container {
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; margin-bottom: 20px; } padding: 32px 40px;
.back-link:hover { color: var(--accent-primary); } max-width: 1400px;
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; } }
.loading-center p { color: var(--text-muted); }
.loading-state { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.submission-header { margin-bottom: 28px; } .loader { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: #667eea; border-radius: 50%; animation: spin 0.8s linear infinite; }
.header-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; } @keyframes spin { to { transform: rotate(360deg); } }
.header-row h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0; } .loading-state p { color: var(--text-secondary); }
.sub-info { font-size: 14px; color: var(--text-muted); margin: 4px 0 0; }
.score-display { text-align: right; } .breadcrumb { display: flex; align-items: center; gap: 8px; margin-bottom: 28px; flex-wrap: wrap; }
.breadcrumb a { color: #667eea; text-decoration: none; font-size: 14px; font-weight: 500; }
.breadcrumb a:hover { color: #8b9cf7; }
.breadcrumb span { color: var(--text-muted); font-size: 13px; }
.breadcrumb .current { color: var(--text-primary); font-weight: 600; }
.score-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 36px; }
.score-card {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px;
padding: 20px; display: flex; flex-direction: column; gap: 8px;
}
.score-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.score-value { font-size: 24px; font-weight: 700; color: var(--text-primary); } .score-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.score-percent { display: block; font-size: 16px; font-weight: 600; margin-top: 2px; } .score-value.small { font-size: 14px; }
.score-percent.good { color: var(--success); } .score-value.good { color: var(--success); }
.score-percent.avg { color: var(--warning); } .score-value.avg { color: var(--warning); }
.score-percent.poor { color: var(--danger); } .score-value.poor { color: var(--danger); }
.meta-row { display: flex; gap: 24px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-subtle); } .section-title { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); }
.meta-row span { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-row .material-symbols-rounded { font-size: 18px; color: var(--text-muted); } .questions-list { display: flex; flex-direction: column; gap: 20px; }
.question-card {
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; } background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px;
padding: 24px; transition: all 0.2s;
.answer-card { margin-bottom: 12px; border-left: 4px solid transparent; } }
.answer-card.correct { border-left-color: var(--success); } .question-card.correct { border-left: 4px solid var(--success); }
.answer-card.wrong { border-left-color: var(--danger); } .question-card.wrong { border-left: 4px solid var(--danger); }
.answer-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .q-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.q-num { font-size: 12px; font-weight: 700; color: var(--accent-primary); background: var(--accent-primary-light); padding: 3px 10px; border-radius: var(--radius-full); } .q-number { background: rgba(102,126,234,0.15); color: var(--accent-primary); padding: 4px 12px; border-radius: 8px; font-weight: 700; font-size: 13px; }
.result-icon { font-size: 22px; } .q-type-badge { background: var(--bg-input); color: var(--text-secondary); padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.answer-card.correct .result-icon { color: var(--success); } .q-status { margin-left: auto; font-weight: 600; font-size: 13px; }
.answer-card.wrong .result-icon { color: var(--danger); } .q-status.correct { color: var(--success); }
.q-status:not(.correct) { color: var(--danger); }
.question-text { font-size: 15px; color: var(--text-primary); font-weight: 500; margin-bottom: 12px; line-height: 1.5; }
.answer-details { display: flex; flex-direction: column; gap: 6px; } .q-text { font-size: 15px; line-height: 1.6; margin: 0 0 16px; color: var(--text-primary); }
.answer-row { display: flex; gap: 8px; font-size: 13px; }
.answer-label { color: var(--text-muted); min-width: 130px; } .options-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.answer-value { color: var(--text-primary); font-weight: 500; } .option {
.wrong-text { color: var(--danger); } padding: 12px 16px; border-radius: 10px; font-size: 14px; color: var(--text-primary);
.correct-text { color: var(--success); } background: var(--bg-input); border: 1px solid var(--border-color);
display: flex; align-items: center; gap: 8px;
@media (max-width: 768px) { .page-container { padding: 20px 16px; } } }
.option.correct-answer { background: rgba(74,222,128,0.1); border-color: rgba(74,222,128,0.3); color: var(--success); }
.option.wrong-answer { background: rgba(248,113,113,0.1); border-color: rgba(248,113,113,0.3); color: var(--danger); }
.option.student-answer:not(.wrong-answer) { background: rgba(74,222,128,0.1); border-color: rgba(74,222,128,0.3); color: var(--success); }
.option-marker { font-weight: 700; font-size: 16px; }
@media (max-width: 768px) {
.page-container { padding: 24px 16px; }
.options-grid { grid-template-columns: 1fr; }
.score-summary { grid-template-columns: 1fr 1fr; }
}
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<a routerLink="/hr/candidates" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Candidates
</a>
@if (loading()) { @if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading submission...</p></div> <div class="loading-state"><div class="loader"></div><p>Loading details...</p></div>
} @else if (submission()) { } @else if (submission()) {
<div class="submission-header card card-padding"> <div class="breadcrumb">
<div class="header-row"> <a routerLink="/admin/users">Users</a> <span></span>
<div> <a [routerLink]="['/admin/users', submission().student?.id || submission().student?._id, 'history']">{{ submission().student?.name }}</a>
<h1>{{ submission().quizId?.title || 'Quiz' }}</h1> <span></span> <span class="current">{{ submission().quiz?.title }}</span>
<p class="sub-info">{{ submission().studentId?.name }} • {{ submission().studentId?.email }}</p>
</div> </div>
<div class="score-display">
<!-- Score Summary -->
<div class="score-summary">
<div class="score-card">
<span class="score-label">Score</span>
<span class="score-value">{{ submission().score }}/{{ submission().totalMarks }}</span> <span class="score-value">{{ submission().score }}/{{ submission().totalMarks }}</span>
<span class="score-percent" [class.good]="submission().percentage >= 70"
[class.avg]="submission().percentage >= 40 && submission().percentage < 70"
[class.poor]="submission().percentage < 40">{{ submission().percentage }}%</span>
</div> </div>
<div class="score-card">
<span class="score-label">Percentage</span>
<span class="score-value" [class.good]="submission().percentage >= 70" [class.avg]="submission().percentage >= 40 && submission().percentage < 70" [class.poor]="submission().percentage < 40">{{ submission().percentage }}%</span>
</div> </div>
<div class="meta-row"> <div class="score-card">
<span><span class="material-symbols-rounded">timer</span> {{ formatTime(submission().timeTaken) }}</span> <span class="score-label">Time Taken</span>
<span><span class="material-symbols-rounded">calendar_today</span> {{ submission().submittedAt | date:'medium' }}</span> <span class="score-value">{{ formatTime(submission().timeTaken) }}</span>
</div> </div>
<div class="score-card">
<span class="score-label">Submitted</span>
<span class="score-value small">{{ submission().submittedAt | date:'medium' }}</span>
</div> </div>
<h2 class="section-title">Answers</h2>
@for (ans of detailedAnswers(); track $index; let i = $index) {
<div class="card card-padding answer-card" [class.correct]="ans.isCorrect" [class.wrong]="!ans.isCorrect">
<div class="answer-header">
<span class="q-num">Q{{ i + 1 }}</span>
<span class="material-symbols-rounded result-icon">{{ ans.isCorrect ? 'check_circle' : 'cancel' }}</span>
</div>
<p class="question-text">{{ ans.question }}</p>
<div class="answer-details">
<div class="answer-row">
<span class="answer-label">Student's Answer:</span>
<span class="answer-value" [class.wrong-text]="!ans.isCorrect">{{ ans.studentAnswer || '—' }}</span>
</div> </div>
<div class="answer-row">
<span class="answer-label">Correct Answer:</span> <!-- Questions -->
<span class="answer-value correct-text">{{ ans.correctAnswer }}</span> <h2 class="section-title">Detailed Answers</h2>
<div class="questions-list">
@for (q of detailedAnswers(); track q.questionId; let i = $index) {
<div class="question-card" [class.correct]="q.isCorrect" [class.wrong]="!q.isCorrect">
<div class="q-header">
<span class="q-number">Q{{ i + 1 }}</span>
<span class="q-type-badge">{{ q.type === 'mcq' ? 'MCQ' : 'Single' }}</span>
<span class="q-status" [class.correct]="q.isCorrect">{{ q.isCorrect ? '✓ Correct' : '✗ Wrong' }}</span>
</div>
<p class="q-text">{{ q.question }}</p>
<div class="options-grid">
@for (opt of q.options; track opt) {
<div class="option"
[class.correct-answer]="q.correctAnswers.includes(opt)"
[class.student-answer]="q.studentAnswers.includes(opt)"
[class.wrong-answer]="q.studentAnswers.includes(opt) && !q.correctAnswers.includes(opt)">
<span class="option-marker">
@if (q.correctAnswers.includes(opt)) { ✓ }
@else if (q.studentAnswers.includes(opt)) { ✗ }
</span>
{{ opt }}
</div> </div>
}
</div> </div>
</div> </div>
} }
</div>
} }
</div> </div>
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
@Component({ @Component({
...@@ -13,13 +14,21 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -13,13 +14,21 @@ import { QuizService } from '../../../services/quiz.service';
export class HRSubmissionDetailComponent implements OnInit { export class HRSubmissionDetailComponent implements OnInit {
submission = signal<any>(null); submission = signal<any>(null);
detailedAnswers = signal<any[]>([]); detailedAnswers = signal<any[]>([]);
loading = signal(true); loading = signal<boolean>(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {} constructor(
private route: ActivatedRoute,
public authService: AuthService,
private quizService: QuizService
) {}
ngOnInit(): void { ngOnInit(): void {
const id = this.route.snapshot.params['submissionId']; const submissionId = this.route.snapshot.params['submissionId'];
this.quizService.getHRSubmissionDetails(id).subscribe({ this.loadDetails(submissionId);
}
loadDetails(id: string): void {
this.quizService.getSubmissionDetails(id).subscribe({
next: (res) => { next: (res) => {
this.submission.set(res.submission); this.submission.set(res.submission);
this.detailedAnswers.set(res.detailedAnswers); this.detailedAnswers.set(res.detailedAnswers);
...@@ -34,4 +43,8 @@ export class HRSubmissionDetailComponent implements OnInit { ...@@ -34,4 +43,8 @@ export class HRSubmissionDetailComponent implements OnInit {
const s = seconds % 60; const s = seconds % 60;
return `${m}m ${s}s`; return `${m}m ${s}s`;
} }
logout(): void {
this.authService.logout();
}
} }
.page-container {
padding: 32px 40px;
max-width: 1400px;
}
.breadcrumb {
margin-bottom: 24px;
}
.breadcrumb a {
color: #667eea;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.breadcrumb a:hover {
color: #8b9cf7;
}
.student-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
padding: 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
.student-profile {
display: flex;
align-items: center;
gap: 20px;
}
.student-avatar {
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-weight: 700;
font-size: 28px;
}
.student-info h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px;
}
.student-info p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.section-title {
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
margin: 0 0 20px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 16px;
}
.loader {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 80px 0;
}
.empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
.empty-state h3 {
color: var(--text-primary);
font-size: 18px;
margin: 0 0 8px;
}
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.history-table-wrap {
overflow-x: auto;
border-radius: 16px;
border: 1px solid var(--border-color);
}
.history-table {
width: 100%;
border-collapse: collapse;
}
.history-table thead {
background: var(--bg-hover);
}
.history-table th {
padding: 14px 20px;
text-align: left;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-table td {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 14px;
}
.history-table tbody tr {
transition: background 0.2s;
}
.history-table tbody tr:hover {
background: var(--bg-hover);
}
.quiz-name {
font-weight: 600;
}
.score-badge {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
padding: 4px 12px;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
}
.percent-bar {
width: 80px;
height: 6px;
background: var(--bg-input);
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.percent-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.percent-fill.good {
background: linear-gradient(90deg, #4ade80, #22c55e);
}
.percent-fill.avg {
background: linear-gradient(90deg, #fbbf24, #f59e0b);
}
.percent-fill.poor {
background: linear-gradient(90deg, #f87171, #ef4444);
}
.percent-text {
color: var(--text-secondary);
font-size: 13px;
}
.view-btn {
color: #667eea;
text-decoration: none;
font-weight: 600;
font-size: 13px;
transition: color 0.2s;
}
.view-btn:hover {
color: #8b9cf7;
}
@media (max-width: 768px) {
.page-container {
padding: 24px 16px;
}
}
.content-wrapper {
max-width: 1100px;
margin: 0; /* force left alignment */
}
.student-level-control {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.level-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.level-pills {
display: flex;
background: rgba(0, 0, 0, 0.04);
padding: 4px;
border-radius: 12px;
gap: 4px;
}
.level-pill {
border: none;
background: transparent;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.level-pill:hover {
color: var(--text-primary);
background: rgba(0, 0, 0, 0.02);
}
.level-pill.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.level-pill.active[data-level="beginner"] { color: #64748b; }
.level-pill.active[data-level="intermediate"] { color: #3b82f6; }
.level-pill.active[data-level="advanced"] { color: #a855f7; }
.level-pill.active[data-level="expert"] { color: #f59e0b; }
@media (max-width: 640px) {
.student-header {
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.student-level-control {
align-items: flex-start;
width: 100%;
}
.level-pills {
width: 100%;
overflow-x: auto;
}
}
/* Toast Notification */
.toast {
position: fixed;
top: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
z-index: 1000;
color: #fff;
}
.toast-success {
background: linear-gradient(135deg, #10b981, #059669);
}
.toast-error {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.toast .material-symbols-rounded {
font-size: 20px;
}
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<div class="breadcrumb">
<a routerLink="/hr/users">← Back to Users</a>
</div>
@if (loading()) {
<div class="loading-state">
<div class="loader"></div>
<p>Loading history...</p>
</div>
} @else {
@if (user()) {
<div class="student-header">
<div class="student-profile">
<div class="student-avatar">{{ user().name?.charAt(0) }}</div>
<div class="student-info">
<h1>{{ user().name }}</h1>
<p>{{ user().email }}</p>
</div>
</div>
<div class="student-level-control">
<span class="level-label">Candidate Level</span>
<div class="level-pills">
@for (lvl of levels; track lvl.value) {
<button class="level-pill"
[class.active]="currentLevel() === lvl.value"
[attr.data-level]="lvl.value"
(click)="changeLevel(lvl.value)">
{{ lvl.label }}
</button>
}
</div>
</div>
</div>
}
<h2 class="section-title">Test History</h2>
@if (submissions().length === 0) {
<div class="empty-state">
<span class="empty-icon">📋</span>
<h3>No tests taken yet</h3>
<p>This student hasn't taken any quizzes.</p>
</div>
} @else {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>Quiz</th>
<th>Score</th>
<th>Percentage</th>
<th>Time Taken</th>
<th>Date</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sub of submissions(); track sub._id) {
<tr>
<td class="quiz-name">{{ sub.quizId?.title || 'Deleted Quiz' }}</td>
<td><span class="score-badge">{{ sub.score }}/{{ sub.totalMarks }}</span></td>
<td>
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
[class.avg]="sub.percentage >= 40 && sub.percentage < 70"
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
</td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'medium' }}</td>
<td>
<a [routerLink]="['/hr/submissions', sub._id]" class="view-btn">View Details</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
</div>
<!-- Toast Notification -->
@if (toast()) {
<div class="toast animate-fade-in" [class.toast-success]="toast()?.type === 'success'" [class.toast-error]="toast()?.type === 'error'">
<span class="material-symbols-rounded">{{ toast()?.type === 'success' ? 'check_circle' : 'error' }}</span>
{{ toast()?.message }}
</div>
}
\ No newline at end of file
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-user-history',
imports: [CommonModule, RouterLink],
templateUrl: './user-history.html',
styleUrl: './user-history.css',
})
export class HRUserHistoryComponent {
userId = '';
user = signal<any>(null);
submissions = signal<any[]>([]);
loading = signal<boolean>(true);
currentLevel = signal<string>('beginner');
toast = signal<{ message: string; type: 'success' | 'error' } | null>(null);
levels = [
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'expert', label: 'Expert' }
];
constructor(
private route: ActivatedRoute,
public authService: AuthService,
private quizService: QuizService
) {}
ngOnInit(): void {
this.userId = this.route.snapshot.params['userId'];
this.loadHistory();
}
loadHistory(): void {
this.quizService.getUserHistory(this.userId).subscribe({
next: (res) => {
this.user.set(res.user);
this.currentLevel.set(res.user.level || 'beginner');
this.submissions.set(res.submissions);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
changeLevel(newLevel: string): void {
if (newLevel === this.currentLevel()) return;
const previousLevel = this.currentLevel();
const userRole = this.authService.currentUser()?.role || 'hr';
this.quizService.updateUserLevel(this.userId, newLevel, userRole).subscribe({
next: (res) => {
this.currentLevel.set(newLevel);
this.showToast(
`Level changed from ${this.capitalize(res.previousLevel)} to ${this.capitalize(res.newLevel)}`,
'success'
);
},
error: (err) => {
this.showToast(err.error?.message || 'Failed to update level', 'error');
}
});
}
private showToast(message: string, type: 'success' | 'error'): void {
this.toast.set({ message, type });
setTimeout(() => this.toast.set(null), 3500);
}
private capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
logout(): void {
this.authService.logout();
}
}
.page-container {
padding: 32px 40px;
max-width: 1400px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px;
}
.page-header p {
color: var(--text-secondary);
font-size: 15px;
margin: 0;
}
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.tab {
padding: 10px 20px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.tab:hover {
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.tab.active {
background: rgba(102, 126, 234, 0.15);
border-color: rgba(102, 126, 234, 0.3);
color: #667eea;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 16px;
}
.loader {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 80px 0;
}
.empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
.empty-state h3 {
color: var(--text-primary);
font-size: 18px;
margin: 0 0 8px;
}
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.user-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
text-decoration: none;
transition: all 0.25s;
cursor: pointer;
}
.user-card:hover {
background: var(--bg-hover);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}
.user-card-avatar {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-weight: 700;
font-size: 20px;
flex-shrink: 0;
}
.user-card-info {
flex: 1;
}
.name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.user-card-info h4 {
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
margin: 0;
}
.user-card-info p {
color: var(--text-secondary);
font-size: 13px;
margin: 0 0 4px;
}
.user-joined {
color: var(--text-muted);
font-size: 11px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.status-dot.online {
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
.view-arrow {
color: rgba(255, 255, 255, 0.2);
font-size: 20px;
transition: all 0.2s;
}
.user-card:hover .view-arrow {
color: var(--text-secondary);
transform: translateX(4px);
}
.action-btn {
background: transparent;
border: none;
border-radius: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
margin-left: 8px;
}
.action-btn .material-symbols-rounded {
font-size: 20px;
}
.text-danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.level-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.level-badge[data-level="beginner"] {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.level-badge[data-level="intermediate"] {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.level-badge[data-level="advanced"] {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
}
.level-badge[data-level="expert"] {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
@media (max-width: 768px) {
.page-container {
padding: 24px 16px;
}
.users-grid {
grid-template-columns: 1fr;
}
}
\ No newline at end of file
<div class="page-container animate-fade-in">
<div class="page-header">
<h1>Student Users</h1>
<p>View and manage registered students</p>
</div>
<div class="filter-tabs">
<button class="tab" [class.active]="showAll()" (click)="toggleFilter(true)">All Students</button>
<button class="tab" [class.active]="!showAll()" (click)="toggleFilter(false)">Currently Online</button>
</div>
@if (loading()) {
<div class="loading-state">
<div class="loader"></div>
<p>Loading users...</p>
</div>
} @else if (users().length === 0) {
<div class="empty-state">
<span class="empty-icon">👤</span>
<h3>No students found</h3>
<p>{{ showAll() ? 'No students have registered yet.' : 'No students are currently online.' }}</p>
</div>
} @else {
<div class="users-grid">
@for (user of users(); track user._id) {
<a [routerLink]="['/hr/users', user._id, 'history']" class="user-card">
<div class="user-card-avatar">{{ user.name?.charAt(0) || '?' }}</div>
<div class="user-card-info">
<div class="name-row">
<h4>{{ user.name }}</h4>
<span class="level-badge" [attr.data-level]="user.level || 'beginner'">
{{ (user.level || 'beginner') | titlecase }}
</span>
</div>
<p>{{ user.email }}</p>
<span class="user-joined">Joined {{ user.createdAt | date:'mediumDate' }}</span>
</div>
<div class="status-dot" [class.online]="user.isLoggedIn" title="Online Status"></div>
<button class="action-btn text-danger ml-auto" (click)="$event.preventDefault(); $event.stopPropagation(); deleteUser(user._id)" title="Delete Student">
<span class="material-symbols-rounded">delete</span>
</button>
<span class="view-arrow"></span>
</a>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-users',
imports: [CommonModule, RouterLink],
templateUrl: './users.html',
styleUrl: './users.css',
})
export class Users {
users = signal<any[]>([]);
loading = signal<boolean>(true);
showAll = signal<boolean>(true);
constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void {
this.loadUsers();
}
loadUsers(): void {
this.loading.set(true);
const request = this.showAll() ? this.quizService.getUsers('candidate') : this.quizService.getLoggedInUsers();
request.subscribe({
next: (res) => {
const filtered = res.users.filter((u: any) => u.role === 'candidate');
this.users.set(filtered);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
toggleFilter(showAll: boolean): void {
this.showAll.set(showAll);
this.loadUsers();
}
deleteUser(userId: string): void {
if (confirm('Are you sure you want to permanently delete this student user?')) {
this.quizService.deleteUser(userId).subscribe({
next: () => {
this.loadUsers();
},
error: (err) => {
console.error('Failed to delete user', err);
alert('Failed to delete student user.');
}
});
}
}
logout(): void {
this.authService.logout();
}
}
...@@ -19,37 +19,40 @@ ...@@ -19,37 +19,40 @@
} }
getUsers(role?: string): Observable<any> { getUsers(role?: string): Observable<any> {
const url = role ? `${this.adminUrl}/users?role=${role}` : `${this.adminUrl}/users`; const url = role ? `${this.getBaseUrl()}/users?role=${role}` : `${this.getBaseUrl()}/users`;
return this.http.get(url); return this.http.get(url);
} }
getLoggedInUsers(): Observable<any> { getLoggedInUsers(): Observable<any> {
return this.http.get(`${this.adminUrl}/users/logged-in`); return this.http.get(`${this.getBaseUrl()}/users/logged-in`);
} }
createHRUser(data: { name: string; email: string; password: string }): Observable<any> { createHRUser(data: { name: string; email: string; password: string }): Observable<any> {
return this.http.post(`${this.adminUrl}/users/create-hr`, data); return this.http.post(`${this.adminUrl}/users/create-hr`, data); // Only admin can create HR
} }
deleteUser(userId: string): Observable<any> { deleteUser(userId: string): Observable<any> {
return this.http.delete(`${this.adminUrl}/users/${userId}`); return this.http.delete(`${this.getBaseUrl()}/users/${userId}`);
} }
editUser(userId: string, data: { name?: string; email?: string; password?: string }): Observable<any> { editUser(userId: string, data: { name?: string; email?: string; password?: string }): Observable<any> {
return this.http.put(`${this.adminUrl}/users/${userId}`, data); return this.http.put(`${this.getBaseUrl()}/users/${userId}`, data);
} }
getUserHistory(userId: string): Observable<any> { getUserHistory(userId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/users/${userId}/history`); // In HR route it is /candidates/:userId/history but let's check backend hr.js
// Wait, hr.js has /candidates/:userId/history and /candidates instead of /users for HR?
// No, hr.js has /users, /users/logged-in, /users/:userId, etc.
// Wait, let's verify.
return this.http.get(`${this.getBaseUrl()}/users/${userId}/history`);
} }
updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> { updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> {
const baseUrl = role === 'hr' ? this.hrUrl : this.adminUrl; return this.http.put(`${this.getBaseUrl()}/users/${userId}/level`, { level });
return this.http.put(`${baseUrl}/users/${userId}/level`, { level });
} }
getSubmissionDetails(submissionId: string): Observable<any> { getSubmissionDetails(submissionId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/submissions/${submissionId}`); return this.http.get(`${this.getBaseUrl()}/submissions/${submissionId}`);
} }
createQuiz(formData: FormData): Observable<any> { createQuiz(formData: FormData): Observable<any> {
...@@ -146,6 +149,18 @@ ...@@ -146,6 +149,18 @@
return this.http.get(`${this.hrUrl}/groups`); return this.http.get(`${this.hrUrl}/groups`);
} }
assignHRQuiz(quizId: string, data: any): Observable<any> {
return this.http.put(`${this.hrUrl}/quiz/${quizId}/assign`, data);
}
getHRAssignCandidates(quizId: string): Observable<any> {
return this.http.get(`${this.hrUrl}/quiz/${quizId}/assign-candidates`);
}
generateHRAIQuiz(data: any): Observable<any> {
return this.http.post(`${this.hrUrl}/quiz/generate-ai`, data);
}
// ========== CANDIDATE ENDPOINTS ========== // ========== CANDIDATE ENDPOINTS ==========
getAvailableQuizzes(): Observable<any> { getAvailableQuizzes(): Observable<any> {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment