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

feat: Assign candidate functionality added

parent 9cab2abf
...@@ -46,10 +46,6 @@ const quizSchema = new mongoose.Schema({ ...@@ -46,10 +46,6 @@ const quizSchema = new mongoose.Schema({
enum: ['easy', 'medium', 'hard'], enum: ['easy', 'medium', 'hard'],
default: 'medium' default: 'medium'
}, },
topic: {
type: String,
trim: true
},
generatedByAI: { generatedByAI: {
type: Boolean, type: Boolean,
default: false default: false
......
...@@ -32,7 +32,16 @@ const userSchema = new mongoose.Schema({ ...@@ -32,7 +32,16 @@ const userSchema = new mongoose.Schema({
isLoggedIn: { isLoggedIn: {
type: Boolean, type: Boolean,
default: false default: false
} },
level: {
type: String,
enum: ['beginner', 'intermediate', 'advanced', 'expert'],
default: 'beginner'
},
topicsOfInterest: [{
topic: { type: String, required: true },
comfortLevel: { type: Number, min: 0, max: 100, required: true }
}]
}, { }, {
timestamps: true timestamps: true
}); });
......
...@@ -98,6 +98,37 @@ ...@@ -98,6 +98,37 @@
} }
}); });
// @route PUT /api/admin/users/:userId/level
// @desc Update a candidate's level (beginner/intermediate/advanced/expert)
// @access Admin, 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/admin/users/:userId // @route PUT /api/admin/users/:userId
// @desc Edit a user (specifically for HR users to edit email/password) // @desc Edit a user (specifically for HR users to edit email/password)
// @access Admin // @access Admin
...@@ -477,7 +508,6 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt ...@@ -477,7 +508,6 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt
createdBy: req.user._id, createdBy: req.user._id,
category: quizTopic, category: quizTopic,
difficulty: diffLevel, difficulty: diffLevel,
topic: quizTopic,
assignToAll: assignToAll === 'true' || assignToAll === true, assignToAll: assignToAll === 'true' || assignToAll === true,
assignees: assignees || [], assignees: assignees || [],
assignedGroups: assignedGroups || [], assignedGroups: assignedGroups || [],
...@@ -632,7 +662,7 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt ...@@ -632,7 +662,7 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt
}); });
} }
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions } = req.body; const { title, timer, category, difficulty, assignToAll, assignees, assignedGroups, questions } = req.body;
// Update quiz metadata // Update quiz metadata
const updateData = {}; const updateData = {};
...@@ -640,7 +670,6 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt ...@@ -640,7 +670,6 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt
if (timer) updateData.timer = parseInt(timer); if (timer) updateData.timer = parseInt(timer);
if (category) updateData.category = category; if (category) updateData.category = category;
if (difficulty) updateData.difficulty = difficulty; if (difficulty) updateData.difficulty = difficulty;
if (topic !== undefined) updateData.topic = topic;
if (assignToAll !== undefined) updateData.assignToAll = assignToAll; if (assignToAll !== undefined) updateData.assignToAll = assignToAll;
if (assignees) updateData.assignees = assignees; if (assignees) updateData.assignees = assignees;
if (assignedGroups) updateData.assignedGroups = assignedGroups; if (assignedGroups) updateData.assignedGroups = assignedGroups;
...@@ -893,4 +922,98 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt ...@@ -893,4 +922,98 @@ IMPORTANT: The "correct" value must be the EXACT TEXT of the option, not the opt
return a === b; return a === b;
} }
module.exports = router; // @route GET /api/admin/quiz/:quizId/assign-candidates
// @desc Get candidates filtered by quiz category + difficulty using per-topic comfortLevel
// @access Admin
router.get('/quiz/:quizId/assign-candidates', async (req, res) => {
try {
const quiz = await Quiz.findById(req.params.quizId);
if (!quiz) return res.status(404).json({ message: 'Quiz not found' });
const allCandidates = await User.find({ role: 'candidate' });
// 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';
}
// Quiz difficulty → required proficiency mapping:
// Easy → beginner
// 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 requiredProficiencies = getRequiredProficiencies(quizDifficulty);
const filtered = [];
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({
quizTitle: quiz.title,
quizDifficulty: quiz.difficulty,
quizCategory: quiz.category,
assignedIds: assignedIds,
filteredCandidates: filtered
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router;
...@@ -254,4 +254,53 @@ function checkAnswersMatch(arr1, arr2) { ...@@ -254,4 +254,53 @@ function checkAnswersMatch(arr1, arr2) {
return a === b; return a === b;
} }
// @route GET /api/candidate/profile
// @desc Get candidate profile including topics of interest
// @access Candidate
router.get('/profile', async (req, res) => {
try {
const user = await User.findById(req.user._id).select('-password');
if (!user) return res.status(404).json({ message: 'User not found' });
res.json(user);
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/candidate/profile
// @desc Update candidate profile (topics of interest)
// @access Candidate
router.put('/profile', async (req, res) => {
try {
const { topicsOfInterest } = req.body;
if (topicsOfInterest && !Array.isArray(topicsOfInterest)) {
return res.status(400).json({ message: 'Topics of interest must be an array' });
}
const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (topicsOfInterest) {
user.topicsOfInterest = topicsOfInterest;
}
await user.save();
res.json({
message: 'Profile updated successfully',
user: {
_id: user._id,
name: user.name,
email: user.email,
level: user.level,
topicsOfInterest: user.topicsOfInterest
}
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router; module.exports = router;
...@@ -164,13 +164,12 @@ router.put('/quiz/:quizId', async (req, res) => { ...@@ -164,13 +164,12 @@ router.put('/quiz/:quizId', async (req, res) => {
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.', attemptCount });
} }
const { title, timer, category, difficulty, topic, assignToAll, assignees, assignedGroups, questions } = req.body; const { title, timer, category, difficulty, assignToAll, assignees, assignedGroups, questions } = req.body;
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);
if (category) updateData.category = category; if (category) updateData.category = category;
if (difficulty) updateData.difficulty = difficulty; if (difficulty) updateData.difficulty = difficulty;
if (topic !== undefined) updateData.topic = topic;
if (assignToAll !== undefined) updateData.assignToAll = assignToAll; if (assignToAll !== undefined) updateData.assignToAll = assignToAll;
if (assignees) updateData.assignees = assignees; if (assignees) updateData.assignees = assignees;
if (assignedGroups) updateData.assignedGroups = assignedGroups; if (assignedGroups) updateData.assignedGroups = assignedGroups;
...@@ -421,4 +420,37 @@ function checkAnswersMatch(arr1, arr2) { ...@@ -421,4 +420,37 @@ function checkAnswersMatch(arr1, arr2) {
return a === b; return a === b;
} }
// @route PUT /api/hr/users/:userId/level
// @desc Update a candidate's level
// @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.' });
}
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 });
}
});
module.exports = router; module.exports = router;
const jwt = require('jsonwebtoken');
const http = require('http');
const JWT_SECRET = 'quiz_app_super_secret_key_2026';
// The admin user id from the previous query was 69d49c922c5d4255f1586365
const token = jwt.sign({ id: '69d49c922c5d4255f1586365', role: 'admin' }, JWT_SECRET, { expiresIn: '1h' });
const data = JSON.stringify({
assigneeIds: ['69dbd7dc839ed359fa93408a', '69e60a415b06f9656a2dc37d']
});
const options = {
hostname: 'localhost',
port: 5000,
path: '/api/admin/quiz/69e88245efc8e25294852356/assign',
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length,
'Authorization': `Bearer ${token}`
}
};
const req = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
res.on('data', (chunk) => {
console.log(`BODY: ${chunk}`);
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
req.write(data);
req.end();
const mongoose = require('mongoose');
const Quiz = require('./models/Quiz');
require('dotenv').config();
const MONGO_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/quizapp';
async function check() {
await mongoose.connect(MONGO_URI);
console.log('Connected to DB');
const quiz = await Quiz.findOne({});
if (!quiz) {
console.log('No quiz found');
process.exit(0);
}
console.log('Quiz found:', quiz.title, 'ID:', quiz._id.toString());
console.log('Initial assignees:', quiz.assignees);
// simulate PUT request assignToAll = false, assignees = [some mock id]
const mockId = new mongoose.Types.ObjectId();
quiz.assignToAll = false;
quiz.assignees = [mockId];
await quiz.save();
console.log('Saved assignees:', quiz.assignees);
const reloaded = await Quiz.findById(quiz._id);
console.log('Reloaded assignees:', reloaded.assignees);
await mongoose.disconnect();
}
check().catch(console.error);
const mongoose = require('mongoose');
const Quiz = require('./models/Quiz');
require('dotenv').config();
const MONGO_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/quizapp';
async function check() {
await mongoose.connect(MONGO_URI);
console.log('Connected to DB');
const quizzes = await Quiz.find({ title: 'React Basics' });
console.log(`Found ${quizzes.length} React Basics quizzes`);
quizzes.forEach(q => {
console.log(`ID: ${q._id}, assignees: ${q.assignees.length}, createdBy: ${q.createdBy}`);
});
await mongoose.disconnect();
}
check().catch(console.error);
const mongoose = require('mongoose');
const Quiz = require('./models/Quiz');
const User = require('./models/User');
require('dotenv').config();
const MONGO_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/quizapp';
async function check() {
await mongoose.connect(MONGO_URI);
console.log('Connected to DB');
const quiz = await Quiz.findOne({ title: 'React Basics' });
if (!quiz) {
console.log('No React Basics quiz found');
process.exit(0);
}
console.log('Quiz assignees:', quiz.assignees);
const users = await User.find({ role: 'candidate' });
console.log('Candidates IDs:', users.map(u => ({ name: u.name, id: u._id.toString() })));
await mongoose.disconnect();
}
check().catch(console.error);
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
"name": "quiz-app", "name": "quiz-app",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.6", "@angular/cdk": "^21.2.7",
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.0",
...@@ -420,9 +420,9 @@ ...@@ -420,9 +420,9 @@
} }
}, },
"node_modules/@angular/cdk": { "node_modules/@angular/cdk": {
"version": "21.2.6", "version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.6.tgz", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.7.tgz",
"integrity": "sha512-1PBzFf+um/VZ1dFF6cT72Zsq+9C/ZWF9m5dP0uHJgo4psX3yMBoZlZu5YomBiAQ/ePSkqCuryv1vrelK+yd3Mw==", "integrity": "sha512-GHQZ+d5k3nY9JXPNEJpeuLd8FSy03hxXAYsq6IQI4AcTIQow3QZlHj6g3/sk2QlqnzCaEhfRmwx7AO5iXyzdZQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"parse5": "^8.0.0", "parse5": "^8.0.0",
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.6", "@angular/cdk": "^21.2.7",
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.0",
......
...@@ -57,6 +57,10 @@ export const routes: Routes = [ ...@@ -57,6 +57,10 @@ export const routes: Routes = [
path: 'create-quiz', path: 'create-quiz',
loadComponent: () => import('./pages/admin/create-quiz/create-quiz').then(m => m.CreateQuizComponent) loadComponent: () => import('./pages/admin/create-quiz/create-quiz').then(m => m.CreateQuizComponent)
}, },
{
path: 'quiz/:quizId/assign',
loadComponent: () => import('./pages/admin/assign-quiz/assign-quiz').then(m => m.AssignQuizComponent)
},
{ {
path: 'quiz/:quizId/edit', path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/admin/edit-quiz/edit-quiz').then(m => m.EditQuizComponent) loadComponent: () => import('./pages/admin/edit-quiz/edit-quiz').then(m => m.EditQuizComponent)
......
/* ===== 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="/admin/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',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, DragDropModule],
templateUrl: './assign-quiz.html',
styleUrl: './assign-quiz.css'
})
export class AssignQuizComponent implements OnInit {
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.getAssignCandidates(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.assignQuiz(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}"`;
}
}
...@@ -54,8 +54,8 @@ ...@@ -54,8 +54,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Topic</label> <label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="topic" name="topicExcel" placeholder="e.g. Arrays & Loops"> <input class="form-input" [(ngModel)]="category" name="categoryExcel" placeholder="e.g. Java, Angular, Data Structures">
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -86,7 +86,7 @@ ...@@ -86,7 +86,7 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Describe Your Quiz *</label> <label class="form-label">Describe Your Quiz *</label>
<textarea class="form-input form-textarea" [(ngModel)]="topic" name="topic" rows="4" <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> 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"> <p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">lightbulb</span> <span class="material-symbols-rounded" style="font-size:14px">lightbulb</span>
...@@ -109,6 +109,7 @@ ...@@ -109,6 +109,7 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Assign To</label> <label class="form-label">Assign To</label>
<select class="form-select" [(ngModel)]="assignmentType" name="assignmentType"> <select class="form-select" [(ngModel)]="assignmentType" name="assignmentType">
<option value="none">None (Assign Later)</option>
<option value="all">All Candidates</option> <option value="all">All Candidates</option>
<option value="users">Individual People</option> <option value="users">Individual People</option>
<option value="groups">Specific Groups</option> <option value="groups">Specific Groups</option>
......
...@@ -19,7 +19,7 @@ export class CreateQuizComponent implements OnInit { ...@@ -19,7 +19,7 @@ export class CreateQuizComponent implements OnInit {
title = ''; title = '';
timer = 30; timer = 30;
difficulty = 'medium'; difficulty = 'medium';
topic = ''; category = '';
selectedFile: File | null = null; selectedFile: File | null = null;
fileName = signal(''); fileName = signal('');
...@@ -125,7 +125,7 @@ export class CreateQuizComponent implements OnInit { ...@@ -125,7 +125,7 @@ export class CreateQuizComponent implements OnInit {
formData.append('timer', this.timer.toString()); formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile); formData.append('questionsFile', this.selectedFile);
if (this.difficulty) formData.append('difficulty', this.difficulty); if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.topic) formData.append('topic', this.topic); if (this.category) formData.append('category', this.category);
this.appendAssignment(formData); this.appendAssignment(formData);
...@@ -143,14 +143,14 @@ export class CreateQuizComponent implements OnInit { ...@@ -143,14 +143,14 @@ export class CreateQuizComponent implements OnInit {
} }
private submitAI(): void { private submitAI(): void {
if (!this.topic.trim()) { this.error.set('Please describe your quiz topic'); return; } if (!this.category.trim()) { this.error.set('Please describe your quiz topic/category'); return; }
this.loading.set(true); this.loading.set(true);
this.error.set(''); this.error.set('');
this.success.set(''); this.success.set('');
const data: any = { const data: any = {
prompt: this.topic, prompt: this.category,
difficulty: this.difficulty difficulty: this.difficulty
}; };
...@@ -162,6 +162,10 @@ export class CreateQuizComponent implements OnInit { ...@@ -162,6 +162,10 @@ export class CreateQuizComponent implements OnInit {
} else if (this.assignmentType === 'groups') { } else if (this.assignmentType === 'groups') {
data.assignToAll = false; data.assignToAll = false;
data.assignedGroups = this.selectedGroups; data.assignedGroups = this.selectedGroups;
} else if (this.assignmentType === 'none') {
data.assignToAll = false;
data.assignees = [];
data.assignedGroups = [];
} }
this.quizService.generateAIQuiz(data).subscribe({ this.quizService.generateAIQuiz(data).subscribe({
...@@ -186,6 +190,10 @@ export class CreateQuizComponent implements OnInit { ...@@ -186,6 +190,10 @@ export class CreateQuizComponent implements OnInit {
} else if (this.assignmentType === 'groups') { } else if (this.assignmentType === 'groups') {
formData.append('assignToAll', 'false'); formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups)); 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([]));
} }
} }
} }
...@@ -39,15 +39,9 @@ ...@@ -39,15 +39,9 @@
</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" placeholder="e.g. Java, Angular, Data Structures">
<input class="form-input" [(ngModel)]="category" placeholder="Category">
</div>
<div class="form-group">
<label class="form-label">Topic</label>
<input class="form-input" [(ngModel)]="topic" placeholder="Topic">
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -17,7 +17,6 @@ export class EditQuizComponent implements OnInit { ...@@ -17,7 +17,6 @@ export class EditQuizComponent implements OnInit {
timer = 30; timer = 30;
category = ''; category = '';
difficulty = 'medium'; difficulty = 'medium';
topic = '';
questions = signal<any[]>([]); questions = signal<any[]>([]);
loading = signal(true); loading = signal(true);
...@@ -45,7 +44,6 @@ export class EditQuizComponent implements OnInit { ...@@ -45,7 +44,6 @@ export class EditQuizComponent implements OnInit {
this.timer = q.timer; this.timer = q.timer;
this.category = q.category || ''; this.category = q.category || '';
this.difficulty = q.difficulty || 'medium'; this.difficulty = q.difficulty || 'medium';
this.topic = q.topic || '';
this.questions.set(q.questions || []); this.questions.set(q.questions || []);
this.locked.set(res.hasAttempts || false); this.locked.set(res.hasAttempts || false);
this.loading.set(false); this.loading.set(false);
...@@ -87,7 +85,6 @@ onSave(): void { ...@@ -87,7 +85,6 @@ onSave(): void {
timer: this.timer, timer: this.timer,
category: this.category, category: this.category,
difficulty: this.difficulty, difficulty: this.difficulty,
topic: this.topic,
questions: formattedQuestions questions: formattedQuestions
}; // ✅ closes data object }; // ✅ closes data object
......
...@@ -51,10 +51,10 @@ ...@@ -51,10 +51,10 @@
{{ quiz.category || 'Uncategorized' }} {{ quiz.category || 'Uncategorized' }}
</div> </div>
</div> </div>
@if (quiz.topic) {
<div class="quiz-topic">{{ quiz.topic }}</div>
}
<div class="quiz-card-actions"> <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) { @if (quiz.attemptCount > 0) {
<span class="attempted-badge"> <span class="attempted-badge">
<span class="material-symbols-rounded">lock</span> <span class="material-symbols-rounded">lock</span>
......
.page-container { .page-container {
padding: 32px 40px; padding: 32px 40px;
max-width: 1400px; max-width: 1400px;
} }
.breadcrumb { .breadcrumb {
margin-bottom: 24px; margin-bottom: 24px;
} }
.breadcrumb a { .breadcrumb a {
color: #667eea; color: #667eea;
text-decoration: none; text-decoration: none;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
transition: color 0.2s; transition: color 0.2s;
} }
.breadcrumb a:hover { .breadcrumb a:hover {
color: #8b9cf7; color: #8b9cf7;
} }
.student-header { .student-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; justify-content: space-between;
margin-bottom: 32px; margin-bottom: 32px;
} padding: 24px;
background: var(--bg-card);
.student-avatar { border: 1px solid var(--border-color);
width: 64px; border-radius: 16px;
height: 64px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border-radius: 18px; }
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; .student-profile {
align-items: center; display: flex;
justify-content: center; align-items: center;
color: var(--text-primary); gap: 20px;
font-weight: 700; }
font-size: 28px;
} .student-avatar {
width: 64px;
.student-header h1 { height: 64px;
font-size: 24px; border-radius: 18px;
font-weight: 700; background: linear-gradient(135deg, #667eea, #764ba2);
color: var(--text-primary); display: flex;
margin: 0 0 4px; align-items: center;
} justify-content: center;
color: #ffffff;
.student-header p { font-weight: 700;
color: var(--text-secondary); font-size: 28px;
font-size: 14px; }
margin: 0;
} .student-info h1 {
font-size: 24px;
.section-title { font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-size: 18px; margin: 0 0 4px;
font-weight: 600; }
margin: 0 0 20px;
} .student-info p {
color: var(--text-secondary);
.loading-state { font-size: 14px;
display: flex; margin: 0;
flex-direction: column; }
align-items: center;
justify-content: center; .section-title {
padding: 80px 0; color: var(--text-primary);
gap: 16px; font-size: 18px;
} font-weight: 600;
margin: 0 0 20px;
.loader { }
width: 40px;
height: 40px; .loading-state {
border: 3px solid rgba(255, 255, 255, 0.1); display: flex;
border-top-color: #667eea; flex-direction: column;
border-radius: 50%; align-items: center;
animation: spin 0.8s linear infinite; justify-content: center;
} padding: 80px 0;
gap: 16px;
@keyframes spin { }
to {
transform: rotate(360deg); .loader {
} width: 40px;
} height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
.loading-state p { border-top-color: #667eea;
color: var(--text-secondary); border-radius: 50%;
} animation: spin 0.8s linear infinite;
}
.empty-state {
text-align: center; @keyframes spin {
padding: 80px 0; to {
} transform: rotate(360deg);
}
.empty-icon { }
font-size: 48px;
display: block; .loading-state p {
margin-bottom: 16px; color: var(--text-secondary);
} }
.empty-state h3 { .empty-state {
color: var(--text-primary); text-align: center;
font-size: 18px; padding: 80px 0;
margin: 0 0 8px; }
}
.empty-icon {
.empty-state p { font-size: 48px;
color: var(--text-secondary); display: block;
font-size: 14px; margin-bottom: 16px;
margin: 0; }
}
.empty-state h3 {
.history-table-wrap { color: var(--text-primary);
overflow-x: auto; font-size: 18px;
border-radius: 16px; margin: 0 0 8px;
border: 1px solid var(--border-color); }
}
.empty-state p {
.history-table { color: var(--text-secondary);
width: 100%; font-size: 14px;
border-collapse: collapse; margin: 0;
} }
.history-table thead { .history-table-wrap {
background: var(--bg-hover); overflow-x: auto;
} border-radius: 16px;
border: 1px solid var(--border-color);
.history-table th { }
padding: 14px 20px;
text-align: left; .history-table {
color: var(--text-secondary); width: 100%;
font-size: 12px; border-collapse: collapse;
font-weight: 600; }
text-transform: uppercase;
letter-spacing: 0.5px; .history-table thead {
} background: var(--bg-hover);
}
.history-table td {
padding: 16px 20px; .history-table th {
border-top: 1px solid var(--border-color); padding: 14px 20px;
color: var(--text-primary); text-align: left;
font-size: 14px; color: var(--text-secondary);
} font-size: 12px;
font-weight: 600;
.history-table tbody tr { text-transform: uppercase;
transition: background 0.2s; letter-spacing: 0.5px;
} }
.history-table tbody tr:hover { .history-table td {
background: var(--bg-hover); padding: 16px 20px;
} border-top: 1px solid var(--border-color);
color: var(--text-primary);
.quiz-name { font-size: 14px;
font-weight: 600; }
}
.history-table tbody tr {
.score-badge { transition: background 0.2s;
background: rgba(102, 126, 234, 0.15); }
color: #667eea;
padding: 4px 12px; .history-table tbody tr:hover {
border-radius: 8px; background: var(--bg-hover);
font-weight: 600; }
font-size: 13px;
} .quiz-name {
font-weight: 600;
.percent-bar { }
width: 80px;
height: 6px; .score-badge {
background: var(--bg-input); background: rgba(102, 126, 234, 0.15);
border-radius: 3px; color: #667eea;
overflow: hidden; padding: 4px 12px;
display: inline-block; border-radius: 8px;
vertical-align: middle; font-weight: 600;
margin-right: 8px; font-size: 13px;
} }
.percent-fill { .percent-bar {
height: 100%; width: 80px;
border-radius: 3px; height: 6px;
transition: width 0.5s ease; background: var(--bg-input);
} border-radius: 3px;
overflow: hidden;
.percent-fill.good { display: inline-block;
background: linear-gradient(90deg, #4ade80, #22c55e); vertical-align: middle;
} margin-right: 8px;
}
.percent-fill.avg {
background: linear-gradient(90deg, #fbbf24, #f59e0b); .percent-fill {
} height: 100%;
border-radius: 3px;
.percent-fill.poor { transition: width 0.5s ease;
background: linear-gradient(90deg, #f87171, #ef4444); }
}
.percent-fill.good {
.percent-text { background: linear-gradient(90deg, #4ade80, #22c55e);
color: var(--text-secondary); }
font-size: 13px;
} .percent-fill.avg {
background: linear-gradient(90deg, #fbbf24, #f59e0b);
.view-btn { }
color: #667eea;
text-decoration: none; .percent-fill.poor {
font-weight: 600; background: linear-gradient(90deg, #f87171, #ef4444);
font-size: 13px; }
transition: color 0.2s;
} .percent-text {
color: var(--text-secondary);
.view-btn:hover { font-size: 13px;
color: #8b9cf7; }
}
.view-btn {
@media (max-width: 768px) { color: #667eea;
.page-container { text-decoration: none;
padding: 24px 16px; font-weight: 600;
} font-size: 13px;
} transition: color 0.2s;
}
.content-wrapper {
max-width: 1100px; .view-btn:hover {
margin: 0; /* force left alignment */ color: #8b9cf7;
} }
\ No newline at end of file
@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;
}
\ No newline at end of file
...@@ -12,10 +12,26 @@ ...@@ -12,10 +12,26 @@
} @else { } @else {
@if (user()) { @if (user()) {
<div class="student-header"> <div class="student-header">
<div class="student-avatar">{{ user().name?.charAt(0) }}</div> <div class="student-profile">
<div> <div class="student-avatar">{{ user().name?.charAt(0) }}</div>
<h1>{{ user().name }}</h1> <div class="student-info">
<p>{{ user().email }}</p> <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>
</div> </div>
} }
...@@ -68,4 +84,12 @@ ...@@ -68,4 +84,12 @@
} }
} }
</div> </div>
</div> </div>
\ No newline at end of file
<!-- 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
...@@ -16,6 +16,15 @@ export class UserHistoryComponent implements OnInit { ...@@ -16,6 +16,15 @@ export class UserHistoryComponent implements OnInit {
user = signal<any>(null); user = signal<any>(null);
submissions = signal<any[]>([]); submissions = signal<any[]>([]);
loading = signal<boolean>(true); 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( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
...@@ -32,6 +41,7 @@ export class UserHistoryComponent implements OnInit { ...@@ -32,6 +41,7 @@ export class UserHistoryComponent implements OnInit {
this.quizService.getUserHistory(this.userId).subscribe({ this.quizService.getUserHistory(this.userId).subscribe({
next: (res) => { next: (res) => {
this.user.set(res.user); this.user.set(res.user);
this.currentLevel.set(res.user.level || 'beginner');
this.submissions.set(res.submissions); this.submissions.set(res.submissions);
this.loading.set(false); this.loading.set(false);
}, },
...@@ -39,6 +49,35 @@ export class UserHistoryComponent implements OnInit { ...@@ -39,6 +49,35 @@ export class UserHistoryComponent implements OnInit {
}); });
} }
changeLevel(newLevel: string): void {
if (newLevel === this.currentLevel()) return;
const previousLevel = this.currentLevel();
const userRole = this.authService.currentUser()?.role || 'admin';
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 { formatTime(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;
......
...@@ -146,11 +146,18 @@ color: var(--text-primary); ...@@ -146,11 +146,18 @@ color: var(--text-primary);
flex: 1; flex: 1;
} }
.name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.user-card-info h4 { .user-card-info h4 {
color: var(--text-primary); color: var(--text-primary);
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
margin: 0 0 4px; margin: 0;
} }
.user-card-info p { .user-card-info p {
...@@ -212,6 +219,36 @@ color: var(--text-secondary); ...@@ -212,6 +219,36 @@ color: var(--text-secondary);
color: #ef4444; 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) { @media (max-width: 768px) {
.page-container { .page-container {
padding: 24px 16px; padding: 24px 16px;
......
...@@ -26,11 +26,17 @@ ...@@ -26,11 +26,17 @@
<a [routerLink]="['/admin/users', user._id, 'history']" class="user-card"> <a [routerLink]="['/admin/users', 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">
<h4>{{ user.name }}</h4> <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> <p>{{ user.email }}</p>
<span class="user-joined">Joined {{ user.createdAt | date:'mediumDate' }}</span> <span class="user-joined">Joined {{ user.createdAt | date:'mediumDate' }}</span>
</div> </div>
<div class="status-dot" [class.online]="user.isLoggedIn"></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"> <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> <span class="material-symbols-rounded">delete</span>
......
...@@ -9,23 +9,35 @@ ...@@ -9,23 +9,35 @@
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; } .quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.quiz-card { padding: 24px; display: flex; flex-direction: column; gap: 12px; } .quiz-card { padding: 24px; display: flex; flex-direction: column; gap: 12px; height: 100%; box-sizing: border-box; }
.quiz-card.taken { opacity: 0.85; } .quiz-card.taken { opacity: 0.85; }
.quiz-card-header { display: flex; align-items: center; justify-content: space-between; } .quiz-card-header { display: flex; align-items: flex-start; justify-content: flex-start; gap: 12px; }
.quiz-icon-wrap { .quiz-icon-wrap {
width: 40px; height: 40px; border-radius: var(--radius-md); width: 40px; height: 40px; border-radius: var(--radius-md); flex-shrink: 0;
background: var(--accent-primary-light); display: flex; align-items: center; justify-content: center; background: var(--accent-primary-light); display: flex; align-items: center; justify-content: center;
} }
.quiz-icon-wrap .material-symbols-rounded { font-size: 22px; color: var(--accent-primary); } .quiz-icon-wrap .material-symbols-rounded { font-size: 22px; color: var(--accent-primary); }
.quiz-icon-wrap.completed { background: var(--success-light); } .quiz-icon-wrap.completed { background: var(--success-light); }
.quiz-icon-wrap.completed .material-symbols-rounded { color: var(--success); } .quiz-icon-wrap.completed .material-symbols-rounded { color: var(--success); }
.quiz-meta { display: flex; gap: 6px; } .quiz-meta { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; justify-content: flex-start; align-items: center; min-width: 0; margin-left: 4px; width: 100%; }
.difficulty-badge { margin-left: auto; }
.quiz-meta .badge {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 15vw; /* Keeps it contained so it truncation kicks in */
}
@media (max-width: 768px) {
.quiz-meta .badge { max-width: 40vw; }
}
.quiz-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; } .quiz-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.quiz-details { display: flex; gap: 16px; } .quiz-details { display: flex; gap: 16px; margin-bottom: 8px; }
.quiz-detail { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-muted); } .quiz-detail { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-muted); }
.quiz-detail .material-symbols-rounded { font-size: 16px; } .quiz-detail .material-symbols-rounded { font-size: 16px; }
...@@ -37,7 +49,7 @@ ...@@ -37,7 +49,7 @@
.fill-poor { background: var(--danger); } .fill-poor { background: var(--danger); }
.result-score { font-size: 13px; color: var(--text-secondary); font-weight: 500; } .result-score { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.quiz-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; } .quiz-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; }
@media (max-width: 768px) { @media (max-width: 768px) {
.page { padding: 20px; } .page { padding: 20px; }
......
...@@ -24,18 +24,18 @@ ...@@ -24,18 +24,18 @@
<div class="quiz-card card" [class.taken]="quiz.taken"> <div class="quiz-card card" [class.taken]="quiz.taken">
<div class="quiz-card-header"> <div class="quiz-card-header">
<div class="quiz-icon-wrap" [class.completed]="quiz.taken"> <div class="quiz-icon-wrap" [class.completed]="quiz.taken">
<span class="material-symbols-rounded">{{ quiz.taken ? 'check_circle' : 'quiz' }}</span> <span class="material-symbols-rounded">{{ quiz.taken ? 'task_alt' : 'description' }}</span>
</div> </div>
<div class="quiz-meta"> <div class="quiz-meta">
@if (quiz.category) { @if (quiz.category) {
<span class="badge badge-primary">{{ quiz.category }}</span> <span class="badge badge-primary" title="{{ quiz.category }}">{{ quiz.category | uppercase }}</span>
} }
@if (quiz.difficulty) { @if (quiz.difficulty) {
<span class="badge" [ngClass]="{ <span class="badge difficulty-badge" [ngClass]="{
'badge-success': quiz.difficulty === 'Beginner', 'badge-success': quiz.difficulty.toLowerCase() === 'easy',
'badge-warning': quiz.difficulty === 'Intermediate', 'badge-warning': quiz.difficulty.toLowerCase() === 'medium',
'badge-danger': quiz.difficulty === 'Advanced' 'badge-danger': quiz.difficulty.toLowerCase() === 'hard'
}">{{ quiz.difficulty }}</span> }">{{ quiz.difficulty | uppercase }}</span>
} }
</div> </div>
</div> </div>
......
...@@ -52,3 +52,202 @@ ...@@ -52,3 +52,202 @@
.stats-row { grid-template-columns: 1fr; } .stats-row { grid-template-columns: 1fr; }
.result-item { flex-wrap: wrap; } .result-item { flex-wrap: wrap; }
} }
/* Topics of Interest Section */
.topics-section {
margin-bottom: 32px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.section-title {
margin-bottom: 0px !important;
}
.saving-indicator {
font-size: 13px;
color: #667eea;
font-weight: 500;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
.section-desc {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
/* Existing Topics Tags */
.existing-topics {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 32px;
}
.no-topics-msg {
color: var(--text-muted);
font-size: 14px;
font-style: italic;
width: 100%;
}
.topic-tag {
display: flex;
align-items: center;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 4px 6px 4px 16px;
gap: 12px;
transition: all 0.2s ease;
}
.topic-tag:hover {
background: var(--bg-card);
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.topic-tag-info {
display: flex;
align-items: center;
gap: 8px;
}
.topic-tag-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.topic-tag-level {
font-size: 12px;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
background: var(--bg-card);
}
.topic-tag-level.high { color: #22c55e; background: rgba(34, 197, 94, 0.1); }
.topic-tag-level.medium { color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
.topic-tag-level.low { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
.topic-tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
cursor: pointer;
transition: all 0.2s;
}
.topic-tag-remove:hover {
background: #ef4444;
color: #fff;
}
.topic-tag-remove .material-symbols-rounded {
font-size: 16px;
}
/* Add New Topic Form */
.add-topic-form {
display: flex;
align-items: flex-end;
gap: 24px;
padding: 20px;
background: rgba(102, 126, 234, 0.03);
border-radius: 12px;
border: 1px dashed rgba(102, 126, 234, 0.3);
}
.topic-input-group,
.topic-slider-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.topic-input-group {
flex: 1.5;
}
.topic-slider-group {
flex: 2;
}
.add-topic-form label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.comfort-value {
font-size: 13px;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
background: var(--bg-card);
}
.comfort-value.high { color: #22c55e; }
.comfort-value.medium { color: #f59e0b; }
.comfort-value.low { color: #ef4444; }
.input-field {
padding: 10px 16px;
border: 1px solid var(--border-color);
background: var(--bg-input);
color: var(--text-primary);
border-radius: 8px;
font-size: 14px;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.slider-field {
width: 100%;
accent-color: #667eea;
}
.btn-add {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
height: 42px; /* Match input heights roughly */
}
@media (max-width: 640px) {
.add-topic-form {
flex-direction: column;
align-items: stretch;
}
}
...@@ -29,6 +29,63 @@ ...@@ -29,6 +29,63 @@
</div> </div>
</div> </div>
<!-- Topics of Interest Section -->
<div class="section topics-section card card-padding">
<div class="section-header">
<h2 class="section-title">Topics of Interest</h2>
@if (isSavingTopics()) {
<span class="saving-indicator">Saving...</span>
}
</div>
<p class="section-desc">Define your preferred topics to help admins assign relevant quizzes based on your comfort level.</p>
<!-- Existing Topics Display -->
<div class="existing-topics">
@for (topic of topics(); track $index) {
<div class="topic-tag">
<div class="topic-tag-info">
<span class="topic-tag-name">{{ topic.topic }}</span>
<span class="topic-tag-level"
[class.high]="topic.comfortLevel >= 75"
[class.medium]="topic.comfortLevel >= 40 && topic.comfortLevel < 75"
[class.low]="topic.comfortLevel < 40">
{{ topic.comfortLevel }}%
</span>
</div>
<button class="topic-tag-remove" (click)="removeTopic($index)" title="Remove topic">
<span class="material-symbols-rounded">close</span>
</button>
</div>
}
@if (topics().length === 0) {
<p class="no-topics-msg">You haven't added any topics yet.</p>
}
</div>
<!-- Add New Topic Form -->
<div class="add-topic-form">
<div class="topic-input-group">
<label>Topic Name</label>
<input type="text" [(ngModel)]="newTopicName" placeholder="e.g., Python, React..." class="input-field" (keyup.enter)="addTopic()" />
</div>
<div class="topic-slider-group">
<div class="slider-header">
<label>Comfort Level</label>
<span class="comfort-value"
[class.high]="newTopicComfort() >= 75"
[class.medium]="newTopicComfort() >= 40 && newTopicComfort() < 75"
[class.low]="newTopicComfort() < 40">
{{ newTopicComfort() }}%
</span>
</div>
<input type="range" [(ngModel)]="newTopicComfort" min="0" max="100" class="slider-field" />
</div>
<button class="btn btn-primary btn-add" (click)="addTopic()" [disabled]="!newTopicName().trim() || isSavingTopics()">
<span class="material-symbols-rounded">add</span> Add
</button>
</div>
</div>
<div class="section"> <div class="section">
<h2 class="section-title">Test Results</h2> <h2 class="section-title">Test Results</h2>
@if (submissions().length === 0) { @if (submissions().length === 0) {
......
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 { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
interface TopicOfInterest {
topic: string;
comfortLevel: number;
}
@Component({ @Component({
selector: 'app-candidate-profile', selector: 'app-candidate-profile',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink], imports: [CommonModule, RouterLink, FormsModule],
templateUrl: './profile.html', templateUrl: './profile.html',
styleUrl: './profile.css' styleUrl: './profile.css'
}) })
...@@ -15,6 +21,14 @@ export class CandidateProfileComponent implements OnInit { ...@@ -15,6 +21,14 @@ export class CandidateProfileComponent implements OnInit {
user = signal<any>(null); user = signal<any>(null);
submissions = signal<any[]>([]); submissions = signal<any[]>([]);
loading = signal(true); loading = signal(true);
topics = signal<TopicOfInterest[]>([]);
// State for new topic form
newTopicName = signal('');
newTopicComfort = signal(50);
isSavingTopics = signal(false);
constructor(public authService: AuthService, private quizService: QuizService) {} constructor(public authService: AuthService, private quizService: QuizService) {}
...@@ -22,6 +36,7 @@ export class CandidateProfileComponent implements OnInit { ...@@ -22,6 +36,7 @@ export class CandidateProfileComponent implements OnInit {
this.quizService.getCandidateProfile().subscribe({ this.quizService.getCandidateProfile().subscribe({
next: (res) => { next: (res) => {
this.user.set(res.user); this.user.set(res.user);
this.topics.set(res.user.topicsOfInterest || []);
this.submissions.set(res.submissions || []); this.submissions.set(res.submissions || []);
this.loading.set(false); this.loading.set(false);
}, },
...@@ -40,4 +55,38 @@ export class CandidateProfileComponent implements OnInit { ...@@ -40,4 +55,38 @@ export class CandidateProfileComponent implements OnInit {
if (subs.length === 0) return 0; if (subs.length === 0) return 0;
return Math.max(...subs.map(s => s.percentage)); return Math.max(...subs.map(s => s.percentage));
} }
addTopic() {
const topic = this.newTopicName().trim();
if (!topic) return;
const newTopicList = [...this.topics(), { topic, comfortLevel: this.newTopicComfort() }];
this.topics.set(newTopicList);
// Clear form
this.newTopicName.set('');
this.newTopicComfort.set(50);
this.saveTopics(newTopicList);
}
removeTopic(index: number) {
const newTopicList = this.topics().filter((_, i) => i !== index);
this.topics.set(newTopicList);
this.saveTopics(newTopicList);
}
private saveTopics(updatedTopics: TopicOfInterest[]) {
this.isSavingTopics.set(true);
this.quizService.updateCandidateProfile({ topicsOfInterest: updatedTopics }).subscribe({
next: () => {
this.isSavingTopics.set(false);
},
error: () => {
this.isSavingTopics.set(false);
alert('Failed to update topics.');
}
});
}
} }
...@@ -37,11 +37,7 @@ ...@@ -37,11 +37,7 @@
<div class="form-row"> <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. Programming"> <input class="form-input" [(ngModel)]="category" name="category" placeholder="e.g. Java, Angular, Data Structures">
</div>
<div class="form-group">
<label class="form-label">Topic</label>
<input class="form-input" [(ngModel)]="topic" name="topic" placeholder="e.g. Arrays & Loops">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
......
...@@ -16,7 +16,6 @@ export class HRCreateQuizComponent { ...@@ -16,7 +16,6 @@ export class HRCreateQuizComponent {
timer = 30; timer = 30;
category = ''; category = '';
difficulty = 'medium'; difficulty = 'medium';
topic = '';
selectedFile: File | null = null; selectedFile: File | null = null;
fileName = signal(''); fileName = signal('');
...@@ -49,7 +48,6 @@ export class HRCreateQuizComponent { ...@@ -49,7 +48,6 @@ export class HRCreateQuizComponent {
formData.append('questionsFile', this.selectedFile); formData.append('questionsFile', this.selectedFile);
if (this.category) formData.append('category', this.category); 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.topic) formData.append('topic', this.topic);
this.quizService.createHRQuiz(formData).subscribe({ this.quizService.createHRQuiz(formData).subscribe({
next: (res) => { next: (res) => {
......
...@@ -43,6 +43,11 @@ ...@@ -43,6 +43,11 @@
return this.http.get(`${this.adminUrl}/users/${userId}/history`); return this.http.get(`${this.adminUrl}/users/${userId}/history`);
} }
updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> {
const baseUrl = role === 'hr' ? this.hrUrl : this.adminUrl;
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.adminUrl}/submissions/${submissionId}`);
} }
...@@ -75,6 +80,10 @@ ...@@ -75,6 +80,10 @@
return this.http.put(`${this.adminUrl}/quiz/${quizId}/assign`, data); return this.http.put(`${this.adminUrl}/quiz/${quizId}/assign`, data);
} }
getAssignCandidates(quizId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/quiz/${quizId}/assign-candidates`);
}
deleteQuiz(quizId: string): Observable<any> { deleteQuiz(quizId: string): Observable<any> {
return this.http.delete(`${this.adminUrl}/quiz/${quizId}`); return this.http.delete(`${this.adminUrl}/quiz/${quizId}`);
} }
...@@ -155,6 +164,10 @@ ...@@ -155,6 +164,10 @@
return this.http.get(`${this.candidateUrl}/profile`); return this.http.get(`${this.candidateUrl}/profile`);
} }
updateCandidateProfile(data: any): Observable<any> {
return this.http.put(`${this.candidateUrl}/profile`, data);
}
getResultDetails(submissionId: string): Observable<any> { getResultDetails(submissionId: string): Observable<any> {
return this.http.get(`${this.candidateUrl}/results/${submissionId}`); return this.http.get(`${this.candidateUrl}/results/${submissionId}`);
} }
......
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