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

refactor : changed the user history from quiz to listing interviesws

parent c23c917d
...@@ -24,7 +24,14 @@ const fileFilter = (req, file, cb) => { ...@@ -24,7 +24,14 @@ const fileFilter = (req, file, cb) => {
'application/vnd.ms-excel', // .xls 'application/vnd.ms-excel', // .xls
'application/pdf', // .pdf 'application/pdf', // .pdf
'application/msword', // .doc 'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/zip', // .zip
'application/x-zip-compressed', // .zip (Windows)
'application/x-rar-compressed', // .rar
'application/octet-stream', // generic binary (fallback for zip)
'image/png',
'image/jpeg',
'image/jpg'
]; ];
if ( if (
...@@ -33,11 +40,16 @@ const fileFilter = (req, file, cb) => { ...@@ -33,11 +40,16 @@ const fileFilter = (req, file, cb) => {
file.originalname.endsWith('.xls') || file.originalname.endsWith('.xls') ||
file.originalname.endsWith('.pdf') || file.originalname.endsWith('.pdf') ||
file.originalname.endsWith('.doc') || file.originalname.endsWith('.doc') ||
file.originalname.endsWith('.docx') file.originalname.endsWith('.docx') ||
file.originalname.endsWith('.zip') ||
file.originalname.endsWith('.rar') ||
file.originalname.endsWith('.png') ||
file.originalname.endsWith('.jpg') ||
file.originalname.endsWith('.jpeg')
) { ) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only Excel, PDF, and Word files are allowed'), false); cb(new Error('Only Excel, PDF, Word, ZIP/RAR, and Image files are allowed'), false);
} }
}; };
...@@ -45,7 +57,7 @@ const upload = multer({ ...@@ -45,7 +57,7 @@ const upload = multer({
storage: storage, storage: storage,
fileFilter: fileFilter, fileFilter: fileFilter,
limits: { limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit fileSize: 25 * 1024 * 1024 // 25MB limit (for coding round zips)
} }
}); });
......
const mongoose = require('mongoose');
const evaluationSchema = new mongoose.Schema({
evaluatorId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
evaluatorRole: { type: String, enum: ['admin', 'hr', 'pm', 'interviewer'], required: true },
comments: { type: String, default: '' },
recommendation: {
type: String,
enum: ['offer', 'on_hold', 'rejected', '2nd_round'],
required: true
},
date: { type: Date, default: Date.now }
}, { _id: true });
const interviewSchema = new mongoose.Schema({
// Candidate info
candidateId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
interviewerId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, // legacy
assignedInterviewers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
assignedHRs: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
assignedPMs: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
// Interview metadata
position: { type: String, required: true, trim: true },
techStack: { type: String, default: '', trim: true },
source: { type: String, default: '', trim: true },
dateOfInterview: { type: Date, default: Date.now },
// Quiz assignments (aptitude + technical)
quizzes: [{
quizId: { type: mongoose.Schema.Types.ObjectId, ref: 'Quiz' },
title: { type: String },
score: { type: Number, default: null },
totalMarks: { type: Number, default: null },
percentage: { type: Number, default: null },
completed: { type: Boolean, default: false }
}],
// Coding round
codingRound: {
zipFile: { type: String, default: '' },
submittedAt: { type: Date },
validated: { type: Boolean, default: false },
validatedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
},
// Evaluations from PM, Interviewer, HR, Admin
evaluations: [evaluationSchema],
// Final decision
finalDecision: {
type: String,
enum: ['pending', 'accepted', 'rejected', 'on_hold', '2nd_round'],
default: 'pending'
},
decidedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
decidedAt: { type: Date },
// Interview status/phase
status: {
type: String,
enum: ['pending', 'quiz_phase', 'coding_phase', 'evaluation', 'completed'],
default: 'pending'
},
// Type
type: {
type: String,
enum: ['individual', 'group'],
default: 'individual'
},
// For group interviews
groupId: { type: String, default: '' },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
}, {
timestamps: true
});
module.exports = mongoose.model('Interview', interviewSchema);
...@@ -23,6 +23,10 @@ const userSchema = new mongoose.Schema({ ...@@ -23,6 +23,10 @@ const userSchema = new mongoose.Schema({
type: String, type: String,
default: '' default: ''
}, },
signature: {
type: String,
default: ''
},
password: { password: {
type: String, type: String,
required: [true, 'Password is required'], required: [true, 'Password is required'],
...@@ -30,7 +34,7 @@ const userSchema = new mongoose.Schema({ ...@@ -30,7 +34,7 @@ const userSchema = new mongoose.Schema({
}, },
role: { role: {
type: String, type: String,
enum: ['admin', 'hr', 'candidate'], enum: ['admin', 'hr', 'candidate', 'pm', 'interviewer'],
default: 'candidate' default: 'candidate'
}, },
group: { group: {
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
router.get('/users', async (req, res) => { router.get('/users', async (req, res) => {
try { try {
const { role } = req.query; const { role } = req.query;
const filter = role ? { role } : { role: { $in: ['candidate', 'hr'] } }; const filter = role ? { role } : { role: { $in: ['candidate', 'hr', 'pm', 'interviewer'] } };
const users = await User.find(filter) const users = await User.find(filter)
.select('-password') .select('-password')
.sort({ createdAt: -1 }); .sort({ createdAt: -1 });
...@@ -47,15 +47,20 @@ ...@@ -47,15 +47,20 @@
} }
}); });
// @route POST /api/admin/users/create-hr // @route POST /api/admin/users/create-staff
// @desc Create an HR user // @desc Create a staff user (HR, PM, Interviewer)
// @access Admin // @access Admin
router.post('/users/create-hr', async (req, res) => { router.post('/users/create-staff', async (req, res) => {
try { try {
const { name, email, password } = req.body; const { name, email, password, role } = req.body;
if (!name || !email || !password) { if (!name || !email || !password || !role) {
return res.status(400).json({ message: 'Please provide name, email, and password' }); return res.status(400).json({ message: 'Please provide name, email, password, and role' });
}
const validRoles = ['hr', 'pm', 'interviewer'];
if (!validRoles.includes(role)) {
return res.status(400).json({ message: 'Invalid role' });
} }
const existingUser = await User.findOne({ email }); const existingUser = await User.findOne({ email });
...@@ -63,10 +68,10 @@ ...@@ -63,10 +68,10 @@
return res.status(400).json({ message: 'User with this email already exists' }); return res.status(400).json({ message: 'User with this email already exists' });
} }
const user = await User.create({ name, email, password, role: 'hr' }); const user = await User.create({ name, email, password, role });
res.status(201).json({ res.status(201).json({
message: 'HR user created successfully', message: 'Staff user created successfully',
user: { user: {
id: user._id, id: user._id,
name: user.name, name: user.name,
...@@ -203,6 +208,30 @@ ...@@ -203,6 +208,30 @@
} }
}); });
// @route GET /api/admin/users/:userId/interviews
// @desc Get all interviews assigned to a specific user
// @access Admin
router.get('/users/:userId/interviews', async (req, res) => {
try {
const { userId } = req.params;
const Interview = require('../models/Interview');
const user = await User.findById(userId).select('-password');
if (!user) return res.status(404).json({ message: 'User not found' });
const interviews = await Interview.find({ candidateId: userId })
.populate('interviewerId', 'name email role')
.populate('createdBy', 'name')
.populate('decidedBy', 'name')
.populate('quizzes.quizId', 'title category difficulty timer totalQuestions')
.sort({ createdAt: -1 });
res.json({ user, interviews });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/submissions/:submissionId // @route GET /api/admin/submissions/:submissionId
// @desc Get detailed submission - answers, correct answers, scores // @desc Get detailed submission - answers, correct answers, scores
// @access Admin // @access Admin
......
...@@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken'); ...@@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken');
const User = require('../models/User'); const User = require('../models/User');
const Group = require('../models/Group'); const Group = require('../models/Group');
const { protect } = require('../middleware/auth'); const { protect } = require('../middleware/auth');
const upload = require('../middleware/upload');
const fs = require('fs');
const path = require('path');
const router = express.Router(); const router = express.Router();
// Generate JWT Token // Generate JWT Token
...@@ -119,6 +122,32 @@ router.get('/me', protect, async (req, res) => { ...@@ -119,6 +122,32 @@ router.get('/me', protect, async (req, res) => {
} }
}); });
// @route POST /api/auth/signature
// @desc Upload digital signature image
// @access Private
router.post('/signature', protect, upload.single('signature'), async (req, res) => {
try {
const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (!req.file) {
return res.status(400).json({ message: 'Please upload an image file' });
}
if (user.signature) {
const oldPath = path.join(__dirname, '..', 'uploads', path.basename(user.signature));
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
user.signature = `/uploads/${req.file.filename}`;
await user.save();
res.json({ message: 'Signature uploaded successfully', signature: user.signature });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/auth/groups // @route GET /api/auth/groups
// @desc Get all groups for registration // @desc Get all groups for registration
// @access Public // @access Public
...@@ -135,4 +164,26 @@ router.get('/groups', async (req, res) => { ...@@ -135,4 +164,26 @@ router.get('/groups', async (req, res) => {
} }
}); });
// @route PUT /api/auth/profile
// @desc Update current user profile
// @access Private
router.put('/profile', protect, upload.none(), async (req, res) => {
try {
const { name, email, phoneNumber } = req.body;
const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (name) user.name = name;
if (email) user.email = email;
if (phoneNumber !== undefined) user.phoneNumber = phoneNumber;
await user.save();
res.json({ message: 'Profile updated', user });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router; module.exports = router;
...@@ -12,6 +12,24 @@ const router = express.Router(); ...@@ -12,6 +12,24 @@ const router = express.Router();
// All candidate routes require authentication + candidate role // All candidate routes require authentication + candidate role
router.use(protect, authorize('candidate')); router.use(protect, authorize('candidate'));
// @route GET /api/candidate/interviews
// @desc Get assigned interviews for the candidate
// @access Candidate
router.get('/interviews', async (req, res) => {
try {
const Interview = require('../models/Interview');
const interviews = await Interview.find({ candidateId: req.user._id })
.populate('interviewerId', 'name email')
.populate('createdBy', 'name')
.populate('quizzes.quizId', 'title timer totalQuestions category difficulty')
.sort({ createdAt: -1 });
res.json({ interviews });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/quizzes // @route GET /api/candidate/quizzes
// @desc Get assigned and available quizzes for the candidate // @desc Get assigned and available quizzes for the candidate
// @access Candidate // @access Candidate
...@@ -86,9 +104,18 @@ router.get('/quiz/:quizId', async (req, res) => { ...@@ -86,9 +104,18 @@ router.get('/quiz/:quizId', async (req, res) => {
// Check assignment - is this candidate allowed to take this quiz? // Check assignment - is this candidate allowed to take this quiz?
const user = await User.findById(req.user._id); const user = await User.findById(req.user._id);
// Check if part of interview
const Interview = require('../models/Interview');
const interview = await Interview.findOne({
candidateId: req.user._id,
'quizzes.quizId': quizId
});
const isAssigned = quiz.assignToAll || const isAssigned = quiz.assignToAll ||
quiz.assignees.some(a => a.toString() === req.user._id.toString()) || quiz.assignees.some(a => a.toString() === req.user._id.toString()) ||
quiz.assignedGroups.includes(user.group); quiz.assignedGroups.includes(user.group) ||
!!interview;
if (!isAssigned) { if (!isAssigned) {
return res.status(403).json({ message: 'You are not assigned to this quiz' }); return res.status(403).json({ message: 'You are not assigned to this quiz' });
...@@ -168,6 +195,20 @@ router.post('/quiz/:quizId/submit', async (req, res) => { ...@@ -168,6 +195,20 @@ router.post('/quiz/:quizId/submit', async (req, res) => {
timeTaken: timeTaken || 0 timeTaken: timeTaken || 0
}); });
// Update Interview if applicable
const Interview = require('../models/Interview');
await Interview.findOneAndUpdate(
{ candidateId: req.user._id, 'quizzes.quizId': quizId },
{
$set: {
'quizzes.$.score': score,
'quizzes.$.totalMarks': totalMarks,
'quizzes.$.percentage': percentage,
'quizzes.$.completed': true
}
}
);
res.status(201).json({ res.status(201).json({
message: 'Quiz submitted successfully', message: 'Quiz submitted successfully',
result: { result: {
...@@ -257,6 +298,44 @@ function checkAnswersMatch(arr1, arr2) { ...@@ -257,6 +298,44 @@ function checkAnswersMatch(arr1, arr2) {
return a === b; return a === b;
} }
// @route POST /api/candidate/interview/:interviewId/coding
// @desc Submit coding round zip file
// @access Candidate
router.post('/interview/:interviewId/coding', upload.single('codingZip'), async (req, res) => {
try {
const Interview = require('../models/Interview');
const { interviewId } = req.params;
const interview = await Interview.findOne({ _id: interviewId, candidateId: req.user._id });
if (!interview) {
return res.status(404).json({ message: 'Interview not found' });
}
if (!req.file) {
return res.status(400).json({ message: 'Please upload a ZIP file' });
}
interview.codingRound = {
zipFile: `/uploads/${req.file.filename}`,
submittedAt: Date.now(),
validated: false
};
// Update status to coding phase evaluation
if (interview.status === 'quiz_phase' || interview.status === 'pending') {
interview.status = 'evaluation';
} else {
interview.status = 'evaluation'; // Since they submitted, it needs eval
}
await interview.save();
res.json({ message: 'Coding round submitted successfully', interview });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/profile // @route GET /api/candidate/profile
// @desc Get candidate profile including topics of interest // @desc Get candidate profile including topics of interest
// @access Candidate // @access Candidate
......
const express = require('express');
const Interview = require('../models/Interview');
const User = require('../models/User');
const Quiz = require('../models/Quiz');
const { protect, authorize } = require('../middleware/auth');
const upload = require('../middleware/upload');
const fs = require('fs');
const path = require('path');
const router = express.Router();
// All interview routes require authentication
router.use(protect);
// ============================================================
// @route POST /api/interview
// @desc Create a new individual interview
// @access Admin, HR, PM
// ============================================================
router.post('/', authorize('admin', 'hr', 'pm'), async (req, res) => {
try {
const { candidateId, interviewerId, assignedInterviewers, assignedHRs, assignedPMs, position, techStack, source, dateOfInterview, quizIds } = req.body;
// Use interviewerId as fallback, or assignedInterviewers[0] as interviewerId for backward compatibility
const mainInterviewerId = interviewerId || (assignedInterviewers && assignedInterviewers.length > 0 ? assignedInterviewers[0] : null);
if (!candidateId || !position) {
return res.status(400).json({ message: 'Candidate and position are required' });
}
// Validate candidate exists and is a candidate
const candidate = await User.findById(candidateId);
if (!candidate) return res.status(404).json({ message: 'Candidate not found' });
// Validate interviewer exists
const interviewer = await User.findById(interviewerId);
if (!interviewer) return res.status(404).json({ message: 'Interviewer not found' });
// Build quiz array if quiz IDs provided
let quizzes = [];
if (quizIds && quizIds.length > 0) {
const quizDocs = await Quiz.find({ _id: { $in: quizIds } }).select('title totalQuestions');
quizzes = quizDocs.map(q => ({
quizId: q._id,
title: q.title,
score: null,
totalMarks: q.totalQuestions,
percentage: null,
completed: false
}));
}
const interview = await Interview.create({
candidateId,
interviewerId: mainInterviewerId,
assignedInterviewers: assignedInterviewers || [],
assignedHRs: assignedHRs || [],
assignedPMs: assignedPMs || [],
position,
techStack: techStack || '',
source: source || '',
dateOfInterview: dateOfInterview || new Date(),
quizzes,
status: quizzes.length > 0 ? 'quiz_phase' : 'pending',
type: 'individual',
createdBy: req.user._id
});
const populated = await Interview.findById(interview._id)
.populate('candidateId', 'name email phoneNumber')
.populate('interviewerId', 'name email')
.populate('assignedInterviewers', 'name email')
.populate('assignedHRs', 'name email')
.populate('assignedPMs', 'name email')
.populate('evaluations.evaluatorId', 'name email signature')
.populate('codingRound.validatedBy', 'name');
res.status(201).json({ message: 'Interview created successfully', interview: populated });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route GET /api/interview
// @desc Get all interviews (filtered by role)
// @access Admin, HR, PM, Interviewer
// ============================================================
router.get('/', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => {
try {
const { status, type } = req.query;
let filter = {};
// Evaluators only see their assigned interviews or created by them (unless Admin)
if (req.user.role !== 'admin') {
filter.$or = [
{ interviewerId: req.user._id },
{ assignedInterviewers: req.user._id },
{ assignedHRs: req.user._id },
{ assignedPMs: req.user._id },
{ createdBy: req.user._id }
];
}
if (status) filter.status = status;
if (type) filter.type = type;
const interviews = await Interview.find(filter)
.populate('candidateId', 'name email phoneNumber resume')
.populate('interviewerId', 'name email')
.populate('assignedInterviewers', 'name email')
.populate('assignedHRs', 'name email')
.populate('assignedPMs', 'name email')
.populate('evaluations.evaluatorId', 'name email signature')
.populate('createdBy', 'name')
.sort({ createdAt: -1 });
res.json({ interviews });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route GET /api/interview/stats
// @desc Get interview statistics
// @access Admin, HR, PM, Interviewer
// ============================================================
router.get('/stats', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => {
try {
let filter = {};
if (req.user.role !== 'admin') {
filter.$or = [
{ interviewerId: req.user._id },
{ assignedInterviewers: req.user._id },
{ assignedHRs: req.user._id },
{ assignedPMs: req.user._id },
{ createdBy: req.user._id }
];
}
const total = await Interview.countDocuments(filter);
const pending = await Interview.countDocuments({ ...filter, status: { $ne: 'completed' } });
const completed = await Interview.countDocuments({ ...filter, status: 'completed' });
const accepted = await Interview.countDocuments({ ...filter, finalDecision: 'accepted' });
const rejected = await Interview.countDocuments({ ...filter, finalDecision: 'rejected' });
res.json({ total, pending, completed, accepted, rejected });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route GET /api/interview/interviewers
// @desc Get list of users who can be interviewers (pm, interviewer, hr, admin)
// @access Admin, HR, PM
// ============================================================
router.get('/interviewers', authorize('admin', 'hr', 'pm'), async (req, res) => {
try {
const interviewers = await User.find({
role: { $in: ['interviewer', 'pm', 'hr', 'admin'] }
}).select('name email role');
res.json({ interviewers });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route GET /api/interview/candidates
// @desc Get list of candidates for interview assignment
// @access Admin, HR, PM
// ============================================================
router.get('/candidates', authorize('admin', 'hr', 'pm'), async (req, res) => {
try {
const candidates = await User.find({ role: 'candidate' })
.select('name email phoneNumber group')
.sort({ name: 1 });
res.json({ candidates });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route GET /api/interview/:id
// @desc Get interview detail
// @access Admin, HR, PM, Interviewer
// ============================================================
router.get('/:id', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => {
try {
const interview = await Interview.findById(req.params.id)
.populate('candidateId', 'name email phoneNumber resume group')
.populate('interviewerId', 'name email role')
.populate('assignedInterviewers', 'name email')
.populate('assignedHRs', 'name email')
.populate('assignedPMs', 'name email')
.populate('createdBy', 'name')
.populate('evaluations.evaluatorId', 'name email role signature')
.populate('codingRound.validatedBy', 'name')
.populate('decidedBy', 'name');
if (!interview) {
return res.status(404).json({ message: 'Interview not found' });
}
// Check authorization to view
if (req.user.role !== 'admin') {
const isAuthorized =
(interview.interviewerId && interview.interviewerId._id.toString() === req.user._id.toString()) ||
interview.assignedInterviewers?.some(u => u.toString() === req.user._id.toString()) ||
interview.assignedHRs?.some(u => u.toString() === req.user._id.toString()) ||
interview.assignedPMs?.some(u => u.toString() === req.user._id.toString()) ||
(interview.createdBy && interview.createdBy._id.toString() === req.user._id.toString());
if (!isAuthorized) {
return res.status(403).json({ message: 'Not authorized to view this interview' });
}
}
res.json({ interview });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route PUT /api/interview/:id
// @desc Update interview details (metadata, status, quiz scores)
// @access Admin, HR, PM
// ============================================================
router.put('/:id', authorize('admin', 'hr', 'pm'), async (req, res) => {
try {
const { position, techStack, source, dateOfInterview, status, quizzes, assignedInterviewers, assignedHRs, assignedPMs } = req.body;
const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' });
if (position) interview.position = position;
if (techStack !== undefined) interview.techStack = techStack;
if (source !== undefined) interview.source = source;
if (dateOfInterview) interview.dateOfInterview = dateOfInterview;
if (status) interview.status = status;
if (quizzes) interview.quizzes = quizzes;
if (assignedInterviewers) interview.assignedInterviewers = assignedInterviewers;
if (assignedHRs) interview.assignedHRs = assignedHRs;
if (assignedPMs) interview.assignedPMs = assignedPMs;
await interview.save();
const populated = await Interview.findById(interview._id)
.populate('candidateId', 'name email phoneNumber')
.populate('interviewerId', 'name email')
.populate('assignedInterviewers', 'name email')
.populate('assignedHRs', 'name email')
.populate('assignedPMs', 'name email');
res.json({ message: 'Interview updated', interview: populated });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route PUT /api/interview/:id/evaluate
// @desc Add an evaluation (comments + recommendation)
// @access Admin, HR, PM, Interviewer
// ============================================================
router.put('/:id/evaluate', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => {
try {
const { comments, recommendation } = req.body;
if (!recommendation) {
return res.status(400).json({ message: 'Recommendation is required' });
}
const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' });
// Check if this user already evaluated — update instead of duplicate
const existingIdx = interview.evaluations.findIndex(
e => e.evaluatorId.toString() === req.user._id.toString()
);
const evaluation = {
evaluatorId: req.user._id,
evaluatorRole: req.user.role,
comments: comments || '',
recommendation,
date: new Date()
};
if (existingIdx >= 0) {
interview.evaluations[existingIdx] = { ...interview.evaluations[existingIdx].toObject(), ...evaluation };
} else {
interview.evaluations.push(evaluation);
}
// Auto-advance to evaluation phase if not already
if (interview.status === 'coding_phase' || interview.status === 'quiz_phase') {
interview.status = 'evaluation';
}
await interview.save();
const populated = await Interview.findById(interview._id)
.populate('candidateId', 'name email')
.populate('interviewerId', 'name email')
.populate('assignedInterviewers', 'name email')
.populate('assignedHRs', 'name email')
.populate('assignedPMs', 'name email')
.populate('evaluations.evaluatorId', 'name email signature')
.populate('codingRound.validatedBy', 'name');
res.json({ message: 'Evaluation submitted', interview: populated });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route PUT /api/interview/:id/decision
// @desc Set final decision (accepted/rejected/on_hold/2nd_round)
// @access Admin only
// ============================================================
router.put('/:id/decision', authorize('admin'), async (req, res) => {
try {
const { decision } = req.body;
if (!decision) return res.status(400).json({ message: 'Decision is required' });
const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' });
interview.finalDecision = decision;
interview.decidedBy = req.user._id;
interview.decidedAt = new Date();
interview.status = 'completed';
await interview.save();
const populated = await Interview.findById(interview._id)
.populate('candidateId', 'name email')
.populate('decidedBy', 'name');
res.json({ message: 'Decision recorded', interview: populated });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route POST /api/interview/:id/coding-submission
// @desc Upload coding round zip file
// @access Admin, HR, PM, Interviewer
// ============================================================
router.post('/:id/coding-submission', authorize('admin', 'hr', 'pm', 'interviewer'), upload.single('codingZip'), async (req, res) => {
try {
const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' });
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
// Delete old file if exists
if (interview.codingRound.zipFile) {
const oldPath = path.join(__dirname, '..', 'uploads', path.basename(interview.codingRound.zipFile));
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
interview.codingRound.zipFile = `/uploads/${req.file.filename}`;
interview.codingRound.submittedAt = new Date();
interview.codingRound.validated = false;
if (interview.status === 'quiz_phase' || interview.status === 'pending') {
interview.status = 'coding_phase';
}
await interview.save();
res.json({ message: 'Coding submission uploaded', interview });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route PUT /api/interview/:id/validate-coding
// @desc Mark coding round as validated
// @access Admin, HR, PM, Interviewer
// ============================================================
router.put('/:id/validate-coding', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => {
try {
const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' });
interview.codingRound.validated = true;
interview.codingRound.validatedBy = req.user._id;
if (interview.status === 'coding_phase') {
interview.status = 'evaluation';
}
await interview.save();
res.json({ message: 'Coding round validated', interview });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route DELETE /api/interview/:id
// @desc Delete an interview
// @access Admin only
// ============================================================
router.delete('/:id', authorize('admin'), async (req, res) => {
try {
const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' });
// Clean up coding zip if exists
if (interview.codingRound.zipFile) {
const filePath = path.join(__dirname, '..', 'uploads', path.basename(interview.codingRound.zipFile));
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
await Interview.findByIdAndDelete(req.params.id);
res.json({ message: 'Interview deleted' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router;
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
app.use('/api/admin', require('./routes/admin')); app.use('/api/admin', require('./routes/admin'));
app.use('/api/hr', require('./routes/hr')); app.use('/api/hr', require('./routes/hr'));
app.use('/api/candidate', require('./routes/candidate')); app.use('/api/candidate', require('./routes/candidate'));
app.use('/api/interview', require('./routes/interview'));
// Keep backward compatibility for old student endpoints // Keep backward compatibility for old student endpoints
app.use('/api/student', require('./routes/candidate')); app.use('/api/student', require('./routes/candidate'));
......
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { adminGuard, hrGuard, candidateGuard, guestGuard } from './guards/auth.guard'; import { adminGuard, hrGuard, candidateGuard, guestGuard, pmGuard, interviewerGuard } from './guards/auth.guard';
import { LayoutComponent } from './components/layout/layout'; import { LayoutComponent } from './components/layout/layout';
export const routes: Routes = [ export const routes: Routes = [
...@@ -42,13 +42,21 @@ export const routes: Routes = [ ...@@ -42,13 +42,21 @@ export const routes: Routes = [
loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent) loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent)
}, },
{ {
path: 'hr-users', path: 'staff/:role',
loadComponent: () => import('./pages/admin/hr-users/hr-users').then(m => m.AdminHrUsersComponent) loadComponent: () => import('./pages/admin/hr-users/hr-users').then(m => m.AdminHrUsersComponent)
}, },
{ {
path: 'submissions/:submissionId', path: 'submissions/:submissionId',
loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent) loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent)
}, },
{
path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'group-interview',
loadComponent: () => import('./pages/admin/group-interview/group-interview').then(m => m.GroupInterviewComponent)
},
{ {
path: 'quizzes', path: 'quizzes',
loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent) loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent)
...@@ -65,6 +73,10 @@ export const routes: Routes = [ ...@@ -65,6 +73,10 @@ export const routes: Routes = [
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)
}, },
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
] ]
}, },
...@@ -111,6 +123,58 @@ export const routes: Routes = [ ...@@ -111,6 +123,58 @@ export const routes: Routes = [
path: 'quiz/:quizId/edit', path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/hr/edit-quiz/edit-quiz').then(m => m.HREditQuizComponent) loadComponent: () => import('./pages/hr/edit-quiz/edit-quiz').then(m => m.HREditQuizComponent)
}, },
{
path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
]
},
// ========== PROJECT MANAGER ROUTES ==========
{
path: 'pm',
component: LayoutComponent,
canActivate: [pmGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'interviews',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
]
},
// ========== INTERVIEWER ROUTES ==========
{
path: 'interviewer',
component: LayoutComponent,
canActivate: [interviewerGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'interviews',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
] ]
}, },
......
...@@ -72,6 +72,16 @@ ...@@ -72,6 +72,16 @@
color: #fff; color: #fff;
} }
.role-pm {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #fff;
}
.role-interviewer {
background: linear-gradient(135deg, #06b6d4, #0891b2);
color: #fff;
}
/* Navigation items */ /* Navigation items */
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
...@@ -354,6 +364,7 @@ ...@@ -354,6 +364,7 @@
} }
/* Glassy Modal Styles */ /* Glassy Modal Styles */
.glassy-overlay { .glassy-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
...@@ -375,11 +386,13 @@ ...@@ -375,11 +386,13 @@
border-radius: 20px; border-radius: 20px;
width: 90%; width: 90%;
max-width: 550px; max-width: 550px;
max-height: 80vh;
padding: 32px; padding: 32px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1); box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
position: relative; position: relative;
overflow: hidden; display: flex;
flex-direction: column;
} }
.modal-header { .modal-header {
...@@ -415,6 +428,9 @@ ...@@ -415,6 +428,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-y: auto;
padding-right: 8px; /* Space for scrollbar */
padding-bottom: 8px;
} }
.glassy-option { .glassy-option {
......
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
<div class="mobile-actions"> <div class="mobile-actions">
<button class="icon-btn" (click)="toggleThemeMenu()"> <button class="icon-btn" (click)="toggleThemeMenu()">
<span class="material-symbols-rounded"> <span class="material-symbols-rounded">
{{ themeService.currentTheme() === 'light' ? 'light_mode' : themeService.currentTheme() === 'dark' ? 'dark_mode' : 'water' }} {{ themeService.currentTheme() === 'light' ? 'light_mode' : themeService.currentTheme() === 'dark' ?
'dark_mode' : 'water' }}
</span> </span>
</button> </button>
</div> </div>
...@@ -44,10 +45,8 @@ ...@@ -44,10 +45,8 @@
<span class="nav-label">{{ item.label }}</span> <span class="nav-label">{{ item.label }}</span>
</a> </a>
} @else { } @else {
<a [routerLink]="item.route" <a [routerLink]="item.route" routerLinkActive="active"
routerLinkActive="active" [routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}" class="nav-item"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}"
class="nav-item"
(click)="mobileSidebarOpen = false"> (click)="mobileSidebarOpen = false">
<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>
...@@ -61,10 +60,7 @@ ...@@ -61,10 +60,7 @@
<div class="section-label">Theme</div> <div class="section-label">Theme</div>
<div class="theme-switcher"> <div class="theme-switcher">
@for (t of themes; track t.id) { @for (t of themes; track t.id) {
<button <button class="theme-btn" [class.active]="themeService.currentTheme() === t.id" (click)="setTheme(t.id)"
class="theme-btn"
[class.active]="themeService.currentTheme() === t.id"
(click)="setTheme(t.id)"
[title]="t.label"> [title]="t.label">
<span class="material-symbols-rounded">{{ t.icon }}</span> <span class="material-symbols-rounded">{{ t.icon }}</span>
</button> </button>
...@@ -81,6 +77,7 @@ ...@@ -81,6 +77,7 @@
<span class="user-email">{{ authService.currentUser()?.email }}</span> <span class="user-email">{{ authService.currentUser()?.email }}</span>
</div> </div>
</div> </div>
<button class="logout-btn" (click)="logout()"> <button class="logout-btn" (click)="logout()">
<span class="material-symbols-rounded">logout</span> <span class="material-symbols-rounded">logout</span>
<span>Sign Out</span> <span>Sign Out</span>
...@@ -97,7 +94,7 @@ ...@@ -97,7 +94,7 @@
<!-- Manage Users Glassy Popup --> <!-- Manage Users Glassy Popup -->
@if (uiService.showManageUsersPopup()) { @if (uiService.showManageUsersPopup()) {
<div class="glassy-overlay" (click)="closeManageUsersPopup()"> <div class="glassy-overlay" (click)="closeManageUsersPopup()">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()"> <div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
<h2>Manage Users & Groups</h2> <h2>Manage Users & Groups</h2>
...@@ -130,7 +127,7 @@ ...@@ -130,7 +127,7 @@
</a> </a>
@if (authService.getUserRole() === 'admin') { @if (authService.getUserRole() === 'admin') {
<a routerLink="/admin/hr-users" class="glassy-option" (click)="closeManageUsersPopup()"> <a routerLink="/admin/staff/hr" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper"> <div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">assignment_ind</span> <span class="material-symbols-rounded block-icon">assignment_ind</span>
</div> </div>
...@@ -140,8 +137,85 @@ ...@@ -140,8 +137,85 @@
</div> </div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span> <span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a> </a>
<a routerLink="/admin/staff/pm" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">work</span>
</div>
<div class="option-text">
<h3>Project Managers</h3>
<p>Manage PMs who assign and track interviews</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
<a routerLink="/admin/staff/interviewer" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">person_search</span>
</div>
<div class="option-text">
<h3>Interviewers</h3>
<p>Manage technical and HR interviewers</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
} }
</div> </div>
</div> </div>
</div>
}
@if (uiService.showInterviewPopup()) {
<div class="glassy-overlay" (click)="uiService.showInterviewPopup.set(false)">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Interview Management</h2>
<button class="close-btn" (click)="uiService.showInterviewPopup.set(false)">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-options horizontal-options">
<a routerLink="/admin/individual-interview" class="glassy-option"
(click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">
person
</span>
</div>
<div class="option-text">
<h3>Individual Interview</h3>
<p>Evaluate technical and one-to-one interviews</p>
</div> </div>
<span class="material-symbols-rounded arrow-icon">
arrow_forward
</span>
</a>
<a routerLink="/admin/group-interview" class="glassy-option" (click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">
groups
</span>
</div>
<div class="option-text">
<h3>Group Interview</h3>
<p>Evaluate group discussion and teamwork rounds</p>
</div>
<span class="material-symbols-rounded arrow-icon">
arrow_forward
</span>
</a>
</div>
</div>
</div>
} }
\ No newline at end of file
...@@ -42,6 +42,7 @@ export class LayoutComponent { ...@@ -42,6 +42,7 @@ export class LayoutComponent {
{ icon: 'group', label: 'Users', route: '/admin/users' }, { icon: 'group', label: 'Users', route: '/admin/users' },
{ icon: 'quiz', label: 'Quizzes', route: '/admin/quizzes' }, { icon: 'quiz', label: 'Quizzes', route: '/admin/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/admin/create-quiz' }, { icon: 'add_circle', label: 'Create Quiz', route: '/admin/create-quiz' },
{ icon: 'person', label: 'Profile', route: '/admin/profile' },
]; ];
case 'hr': case 'hr':
return [ return [
...@@ -49,6 +50,20 @@ export class LayoutComponent { ...@@ -49,6 +50,20 @@ export class LayoutComponent {
{ 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/users' }, { icon: 'people', label: 'Candidates', route: '/hr/users' },
{ icon: 'work', label: 'Interviews', route: '/hr/individual-interview' },
{ icon: 'person', label: 'Profile', route: '/hr/profile' },
];
case 'pm':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/pm/dashboard' },
{ icon: 'work', label: 'Interviews', route: '/pm/interviews' },
{ icon: 'person', label: 'Profile', route: '/pm/profile' },
];
case 'interviewer':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/interviewer/dashboard' },
{ icon: 'assignment_ind', label: 'My Interviews', route: '/interviewer/interviews' },
{ icon: 'person', label: 'Profile', route: '/interviewer/profile' },
]; ];
case 'candidate': case 'candidate':
return [ return [
...@@ -66,6 +81,8 @@ export class LayoutComponent { ...@@ -66,6 +81,8 @@ export class LayoutComponent {
case 'admin': return { label: 'Admin', class: 'role-admin' }; case 'admin': return { label: 'Admin', class: 'role-admin' };
case 'hr': return { label: 'HR', class: 'role-hr' }; case 'hr': return { label: 'HR', class: 'role-hr' };
case 'candidate': return { label: 'Candidate', class: 'role-candidate' }; case 'candidate': return { label: 'Candidate', class: 'role-candidate' };
case 'pm': return { label: 'PM', class: 'role-pm' };
case 'interviewer': return { label: 'Interviewer', class: 'role-interviewer' };
default: return { label: '', class: '' }; default: return { label: '', class: '' };
} }
}); });
......
...@@ -99,3 +99,39 @@ export const guestGuard: CanActivateFn = () => { ...@@ -99,3 +99,39 @@ export const guestGuard: CanActivateFn = () => {
return true; return true;
}; };
// Project Manager only
export const pmGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
if (authService.getUserRole() !== 'pm') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
// Interviewer only
export const interviewerGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
if (authService.getUserRole() !== 'interviewer') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
...@@ -110,22 +110,24 @@ ...@@ -110,22 +110,24 @@
/* Quick Actions */ /* Quick Actions */
.actions-grid { .actions-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(4,1fr);
gap: 16px; gap: 16px;
} }
.action-card { .action-card {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 16px; gap: 16px;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
height: 100%;
} }
.action-icon { .action-icon {
font-size: 28px; font-size: 28px;
color: var(--accent-primary); color: var(--accent-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
} }
.action-info { .action-info {
...@@ -143,12 +145,19 @@ ...@@ -143,12 +145,19 @@
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 38px;
} }
.action-arrow { .action-arrow {
color: var(--text-muted); color: var(--text-muted);
font-size: 20px; font-size: 20px;
transition: transform 0.2s; transition: transform 0.2s;
margin-top: 5px;
} }
.action-card:hover .action-arrow { .action-card:hover .action-arrow {
......
...@@ -70,6 +70,14 @@ ...@@ -70,6 +70,14 @@
<div class="section"> <div class="section">
<h2 class="section-title">Quick Actions</h2> <h2 class="section-title">Quick Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<a (click)="openInterviewPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">groups</span>
<div class="action-info">
<h3>Interviews</h3>
<p>Manage group and individual interview evaluations</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;"> <a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span> <span class="material-symbols-rounded action-icon">group</span>
<div class="action-info"> <div class="action-info">
......
...@@ -32,4 +32,9 @@ export class AdminDashboardComponent implements OnInit { ...@@ -32,4 +32,9 @@ export class AdminDashboardComponent implements OnInit {
openUsersPopup(): void { openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true); this.uiService.showManageUsersPopup.set(true);
} }
openInterviewPopup(): void {
this.uiService.showInterviewPopup.set(true);
}
} }
import { Component } from '@angular/core';
@Component({
selector: 'app-group-interview',
imports: [],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css',
})
export class GroupInterviewComponent{}
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="page-header"> <div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<h1>HR Users</h1> <button class="icon-btn" (click)="goBack()" style="background: var(--bg-card); border: 1px solid var(--border-color); padding: 8px; border-radius: 12px; cursor: pointer; color: var(--text-primary); transition: all 0.2s; display: flex; align-items: center; justify-content: center;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='var(--bg-card)'">
<p class="page-subtitle">Manage HR Staff platform access and identity</p> <span class="material-symbols-rounded">arrow_back</span>
</button>
<div>
<h1 style="margin: 0; font-size: 24px;">{{ getRoleDisplay() }}s</h1>
<p class="page-subtitle" style="margin: 4px 0 0;">Manage {{ getRoleDisplay() }} platform access and identity</p>
</div>
</div> </div>
@if (error()) { @if (error()) {
...@@ -12,7 +17,7 @@ ...@@ -12,7 +17,7 @@
} }
<div class="card card-padding form-card" style="margin-bottom: 40px;"> <div class="card card-padding form-card" style="margin-bottom: 40px;">
<h2 class="section-title">Add HR User</h2> <h2 class="section-title">Add {{ getRoleDisplay() }}</h2>
<form (ngSubmit)="createHRUser()" class="group-form"> <form (ngSubmit)="createHRUser()" class="group-form">
<div class="form-group row-align"> <div class="form-group row-align">
<div class="input-container"> <div class="input-container">
...@@ -32,14 +37,14 @@ ...@@ -32,14 +37,14 @@
@if (creating()) { @if (creating()) {
<span class="spinner"></span> <span class="spinner"></span>
} @else { } @else {
<span class="material-symbols-rounded">person_add</span> Add HR <span class="material-symbols-rounded">person_add</span> Add {{ getRoleDisplay() }}
} }
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<h2 class="section-title">Existing HR Users</h2> <h2 class="section-title">Existing {{ getRoleDisplay() }}s</h2>
@if (loading()) { @if (loading()) {
<div class="loading-state"> <div class="loading-state">
<div class="loader"></div> <div class="loader"></div>
...@@ -47,8 +52,8 @@ ...@@ -47,8 +52,8 @@
} @else if (hrUsers().length === 0) { } @else if (hrUsers().length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="material-symbols-rounded empty-icon">manage_accounts</span> <span class="material-symbols-rounded empty-icon">manage_accounts</span>
<h3>No HR users available</h3> <h3>No {{ getRoleDisplay() }}s available</h3>
<p>Add your first HR user above</p> <p>Add your first {{ getRoleDisplay() }} above</p>
</div> </div>
} @else { } @else {
<div class="groups-grid"> <div class="groups-grid">
......
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
...@@ -18,10 +19,11 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -18,10 +19,11 @@ export class AdminHrUsersComponent implements OnInit {
error = signal<string>(''); error = signal<string>('');
success = signal<string>(''); success = signal<string>('');
// Create New HR // Create New Staff
newName = signal(''); newName = signal('');
newEmail = signal(''); newEmail = signal('');
newPassword = signal(''); newPassword = signal('');
currentRole = signal('hr');
creating = signal(false); creating = signal(false);
// Edit HR // Edit HR
...@@ -31,17 +33,29 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -31,17 +33,29 @@ export class AdminHrUsersComponent implements OnInit {
editPassword = signal(''); editPassword = signal('');
saving = signal(false); saving = signal(false);
constructor(private quizService: QuizService) {} constructor(private quizService: QuizService, private route: ActivatedRoute, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(params => {
const role = params.get('role');
if (role) {
this.currentRole.set(role);
}
this.loadHRUsers(); this.loadHRUsers();
});
} }
loadHRUsers(): void { loadHRUsers(): void {
this.loading.set(true); this.loading.set(true);
this.quizService.getUsers('hr').subscribe({ this.quizService.getUsers().subscribe({
next: (res) => { next: (res) => {
this.hrUsers.set(res.users); // Filter users based on current role
const staff = res.users.filter((u: any) => u.role === this.currentRole());
this.hrUsers.set(staff);
this.loading.set(false); this.loading.set(false);
}, },
error: () => this.loading.set(false) error: () => this.loading.set(false)
...@@ -50,7 +64,7 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -50,7 +64,7 @@ export class AdminHrUsersComponent implements OnInit {
createHRUser(): void { createHRUser(): void {
if (!this.newName().trim() || !this.newEmail().trim() || !this.newPassword().trim()) { if (!this.newName().trim() || !this.newEmail().trim() || !this.newPassword().trim()) {
this.error.set('Please fill out name, email, and password'); this.error.set('Please fill out all fields');
return; return;
} }
...@@ -58,13 +72,14 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -58,13 +72,14 @@ export class AdminHrUsersComponent implements OnInit {
this.error.set(''); this.error.set('');
this.success.set(''); this.success.set('');
this.quizService.createHRUser({ this.quizService.createStaffUser({
name: this.newName().trim(), name: this.newName().trim(),
email: this.newEmail().trim(), email: this.newEmail().trim(),
password: this.newPassword().trim() password: this.newPassword().trim(),
role: this.currentRole()
}).subscribe({ }).subscribe({
next: () => { next: () => {
this.success.set('HR User created successfully!'); this.success.set('Staff User created successfully!');
this.creating.set(false); this.creating.set(false);
this.newName.set(''); this.newName.set('');
this.newEmail.set(''); this.newEmail.set('');
...@@ -72,7 +87,7 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -72,7 +87,7 @@ export class AdminHrUsersComponent implements OnInit {
this.loadHRUsers(); this.loadHRUsers();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to create HR user'); this.error.set(err.error?.message || 'Failed to create staff user');
this.creating.set(false); this.creating.set(false);
} }
}); });
...@@ -101,32 +116,39 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -101,32 +116,39 @@ export class AdminHrUsersComponent implements OnInit {
this.quizService.editUser(userId, data).subscribe({ this.quizService.editUser(userId, data).subscribe({
next: () => { next: () => {
this.success.set('HR User updated successfully!'); this.success.set('Staff User updated successfully!');
this.saving.set(false); this.saving.set(false);
this.cancelEdit(); this.cancelEdit();
this.loadHRUsers(); this.loadHRUsers();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to update HR user'); this.error.set(err.error?.message || 'Failed to update staff user');
this.saving.set(false); this.saving.set(false);
} }
}); });
} }
deleteHRUser(userId: string): void { deleteHRUser(userId: string): void {
if (confirm('Are you sure you want to delete this HR server? This revokes access entirely.')) { if (confirm('Are you sure you want to delete this staff user? This revokes access entirely.')) {
this.error.set(''); this.error.set('');
this.success.set(''); this.success.set('');
this.quizService.deleteUser(userId).subscribe({ this.quizService.deleteUser(userId).subscribe({
next: () => { next: () => {
this.success.set('HR User deleted successfully!'); this.success.set('Staff User deleted successfully!');
this.loadHRUsers(); this.loadHRUsers();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to delete HR user'); this.error.set(err.error?.message || 'Failed to delete staff user');
} }
}); });
} }
} }
getRoleDisplay(): string {
const role = this.currentRole();
if (role === 'hr') return 'HR User';
if (role === 'pm') return 'Project Manager';
if (role === 'interviewer') return 'Interviewer';
return 'Staff User';
}
} }
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1200px; margin: 0; }
.page-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px;
}
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
/* Stats Row */
.stats-row {
display: flex; gap: 16px; margin-bottom: 24px;
}
.mini-stat {
flex: 1; background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 12px; padding: 16px 20px; text-align: center;
}
.mini-stat-value { display: block; font-size: 28px; font-weight: 700; color: var(--text-primary); }
.mini-stat-value.orange { color: #f59e0b; }
.mini-stat-value.blue { color: #3b82f6; }
.mini-stat-value.green { color: #22c55e; }
.mini-stat-value.red { color: #ef4444; }
.mini-stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
/* Filter */
.filter-bar { margin-bottom: 20px; }
.filter-select {
padding: 8px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
/* Interview Cards */
.interview-list { display: flex; flex-direction: column; gap: 16px; }
.interview-card {
cursor: pointer; transition: all 0.2s; border: 1px solid var(--border-color);
border-radius: 16px; background: var(--bg-card);
}
.interview-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 16px rgba(102,126,234,0.1); transform: translateY(-1px); }
.iv-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.iv-candidate { display: flex; align-items: center; gap: 14px; }
.iv-avatar {
width: 44px; height: 44px; border-radius: 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px; flex-shrink: 0;
}
.iv-name { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.iv-badges { display: flex; gap: 8px; }
.iv-card-meta {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px; padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
}
.meta-item {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary);
}
.meta-item .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Progress Steps */
.progress-steps { display: flex; align-items: center; }
.step { display: flex; align-items: center; gap: 6px; }
.step-dot {
width: 10px; height: 10px; border-radius: 50%; background: var(--border-color); transition: all 0.3s;
}
.step.done .step-dot { background: #22c55e; }
.step.active .step-dot { background: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.2); }
.step-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.step.done .step-label { color: #22c55e; }
.step.active .step-label { color: #667eea; font-weight: 600; }
.step-line {
flex: 1; height: 2px; background: var(--border-color); margin: 0 8px; transition: background 0.3s;
}
.step-line.done { background: #22c55e; }
/* Badges */
.badge {
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-warning { background: rgba(245,158,11,0.1); color: #f59e0b; }
.badge-info { background: rgba(59,130,246,0.1); color: #3b82f6; }
.badge-purple { background: rgba(168,85,247,0.1); color: #a855f7; }
.badge-success { background: rgba(34,197,94,0.1); color: #22c55e; }
.badge-danger { background: rgba(239,68,68,0.1); color: #ef4444; }
.badge-muted { background: var(--bg-hover); color: var(--text-muted); }
/* Empty / Loading */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px; gap: 16px; color: var(--text-muted); }
.empty-state { text-align: center; padding: 80px; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 56px; display: block; margin-bottom: 16px; opacity: 0.4; }
.empty-state h3 { color: var(--text-primary); font-size: 18px; margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
/* Modal */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); backdrop-filter: blur(4px);
z-index: 1000; display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: var(--bg-card); border-radius: 16px;
box-shadow: 0 10px 50px rgba(0,0,0,0.25); border: 1px solid var(--border-color);
width: 90%; max-height: 85vh; overflow-y: auto;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-lg { max-width: 640px; }
.modal-xl { max-width: 800px; }
.modal-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; background: var(--bg-card); z-index: 2; border-radius: 16px 16px 0 0;
}
.modal-header h2 { font-size: 18px; font-weight: 600; margin: 0 12px 0 0; display: inline; }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px;
background: var(--bg-hover); position: sticky; bottom: 0; border-radius: 0 0 16px 16px;
}
/* Form */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
transition: all 0.2s; width: 100%; box-sizing: border-box;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.form-textarea { resize: vertical; min-height: 80px; }
/* Quiz Selector */
.quiz-select-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.quiz-select-item {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px;
border: 1px solid var(--border-color); border-radius: 8px;
cursor: pointer; font-size: 13px; color: var(--text-secondary); transition: all 0.2s;
}
.quiz-select-item .material-symbols-rounded { font-size: 18px; }
.quiz-select-item.selected { border-color: #667eea; background: rgba(102,126,234,0.08); color: #667eea; }
.quiz-select-item:hover { border-color: var(--accent-primary); }
/* Detail */
.detail-body { display: flex; flex-direction: column; gap: 24px; }
.detail-section { padding-bottom: 16px; border-bottom: 1px solid var(--border-color); }
.detail-section:last-child { border-bottom: none; }
.detail-section-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; text-transform: uppercase; letter-spacing: 0.5px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-item { display: flex; flex-direction: column; gap: 2px; }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
/* Quiz Results */
.quiz-results-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.quiz-result-card {
padding: 14px; border: 1px solid var(--border-color); border-radius: 10px;
background: var(--bg-hover);
}
.qr-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.qr-score { font-size: 14px; color: #22c55e; font-weight: 700; }
.qr-pending { font-size: 13px; color: var(--text-muted); font-style: italic; }
/* Evaluations */
.eval-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.eval-card {
padding: 16px; border: 1px solid var(--border-color); border-radius: 10px;
background: var(--bg-hover);
}
.eval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.eval-evaluator { display: flex; align-items: center; gap: 8px; }
.eval-evaluator strong { font-size: 14px; color: var(--text-primary); }
.eval-role { font-size: 10px; }
.eval-comments { font-size: 14px; color: var(--text-secondary); margin: 8px 0; font-style: italic; line-height: 1.5; }
.eval-date { font-size: 11px; color: var(--text-muted); }
.eval-form {
margin-top: 16px; padding: 20px; border: 1px dashed rgba(102,126,234,0.3);
border-radius: 12px; background: rgba(102,126,234,0.03);
}
.eval-form h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; }
/* Decision */
.decision-section { padding: 16px; background: rgba(102,126,234,0.03); border-radius: 12px; border: 1px dashed rgba(102,126,234,0.2); }
.decision-buttons { display: flex; gap: 12px; flex-wrap: wrap; }
.btn-success { background: #22c55e; color: #fff; }
.btn-success:hover { background: #16a34a; }
.btn-warning { background: #f59e0b; color: #fff; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.text-muted { color: var(--text-muted); font-size: 13px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (max-width: 768px) {
.page-container { padding: 20px; }
.stats-row { flex-wrap: wrap; }
.mini-stat { min-width: 120px; }
.form-row { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr; }
}
/* Print Styles */
.print-container {
display: none;
}
@media print {
body * {
visibility: hidden;
}
.print-container, .print-container * {
visibility: visible;
}
.print-container {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
background: white;
color: black;
font-family: Arial, sans-serif;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #0078d4;
padding-bottom: 10px;
margin-bottom: 20px;
}
}
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<div class="page-header">
<div>
<h1>Individual Interviews</h1>
<p class="page-subtitle">Manage one-on-one candidate interview evaluations</p>
</div>
<button class="btn btn-primary" (click)="openCreateModal()">
<span class="material-symbols-rounded">add</span>
New Interview
</button>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ stats().total }}</span>
<span class="mini-stat-label">Total</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value orange">{{ stats().pending }}</span>
<span class="mini-stat-label">In Progress</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value blue">{{ stats().completed }}</span>
<span class="mini-stat-label">Completed</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value green">{{ stats().accepted }}</span>
<span class="mini-stat-label">Accepted</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value red">{{ stats().rejected }}</span>
<span class="mini-stat-label">Rejected</span>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<select class="filter-select" [(ngModel)]="filterStatus" (ngModelChange)="onFilterChange()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="quiz_phase">Quiz Phase</option>
<option value="coding_phase">Coding Phase</option>
<option value="evaluation">Evaluation</option>
<option value="completed">Completed</option>
</select>
</div>
<!-- Interview List -->
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading interviews...</p></div>
} @else if (interviews().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">work_off</span>
<h3>No interviews found</h3>
<p>Create your first interview to get started</p>
</div>
} @else {
<div class="interview-list">
@for (iv of interviews(); track iv._id) {
<div class="interview-card card card-padding" (click)="openDetail(iv)">
<div class="iv-card-top">
<div class="iv-candidate">
<div class="iv-avatar">{{ iv.candidateId?.name?.charAt(0)?.toUpperCase() }}</div>
<div>
<h3 class="iv-name">{{ iv.candidateId?.name }}</h3>
<p class="iv-email">{{ iv.candidateId?.email }}</p>
</div>
</div>
<div class="iv-badges">
<span class="badge" [ngClass]="getStatusClass(iv.status)">{{ formatStatus(iv.status) }}</span>
@if (iv.finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(iv.finalDecision)">{{ formatDecision(iv.finalDecision) }}</span>
}
</div>
</div>
<div class="iv-card-meta">
<span class="meta-item"><span class="material-symbols-rounded">work</span> {{ iv.position }}</span>
@if (iv.techStack) {
<span class="meta-item"><span class="material-symbols-rounded">code</span> {{ iv.techStack }}</span>
}
<span class="meta-item"><span class="material-symbols-rounded">person</span> {{ iv.interviewerId?.name }}</span>
<span class="meta-item"><span class="material-symbols-rounded">calendar_today</span> {{ iv.dateOfInterview | date:'mediumDate' }}</span>
</div>
<div class="iv-card-progress">
<div class="progress-steps">
<div class="step" [class.active]="iv.status === 'pending'" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Created</span>
</div>
<div class="step-line" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'quiz_phase'" [class.done]="['coding_phase','evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Quiz</span>
</div>
<div class="step-line" [class.done]="['coding_phase','evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'coding_phase'" [class.done]="['evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Coding</span>
</div>
<div class="step-line" [class.done]="['evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'evaluation'" [class.done]="iv.status === 'completed'">
<span class="step-dot"></span><span class="step-label">Evaluate</span>
</div>
<div class="step-line" [class.done]="iv.status === 'completed'"></div>
<div class="step" [class.active]="iv.status === 'completed'" [class.done]="iv.status === 'completed'">
<span class="step-dot"></span><span class="step-label">Done</span>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- Create Interview Modal -->
@if (showCreateModal()) {
<div class="modal-overlay" (click)="closeCreateModal()">
<div class="modal-container modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Create Individual Interview</h2>
<button class="icon-btn" (click)="closeCreateModal()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">Candidate *</label>
<select class="form-input" [(ngModel)]="newInterview.candidateId">
<option value="">Select candidate</option>
@for (c of candidates(); track c._id) {
<option [value]="c._id">{{ c.name }} ({{ c.email }})</option>
}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label class="form-label">Interviewers</label>
<div class="quiz-select-grid" style="max-height: 120px; overflow-y: auto;">
@for (i of interviewers(); track i._id) {
<label class="quiz-select-item" style="cursor: pointer;">
<input type="checkbox" [value]="i._id" (change)="toggleSelection($event, newInterview.assignedInterviewers)" style="margin-right: 8px;"> {{ i.name }}
</label>
}
@if (interviewers().length === 0) { <p class="text-muted" style="font-size: 12px;">No interviewers found</p> }
</div>
</div>
<div class="form-group" style="flex: 1;">
<label class="form-label">Project Managers</label>
<div class="quiz-select-grid" style="max-height: 120px; overflow-y: auto;">
@for (p of pms(); track p._id) {
<label class="quiz-select-item" style="cursor: pointer;">
<input type="checkbox" [value]="p._id" (change)="toggleSelection($event, newInterview.assignedPMs)" style="margin-right: 8px;"> {{ p.name }}
</label>
}
@if (pms().length === 0) { <p class="text-muted" style="font-size: 12px;">No PMs found</p> }
</div>
</div>
<div class="form-group" style="flex: 1;">
<label class="form-label">HRs</label>
<div class="quiz-select-grid" style="max-height: 120px; overflow-y: auto;">
@for (h of hrs(); track h._id) {
<label class="quiz-select-item" style="cursor: pointer;">
<input type="checkbox" [value]="h._id" (change)="toggleSelection($event, newInterview.assignedHRs)" style="margin-right: 8px;"> {{ h.name }}
</label>
}
@if (hrs().length === 0) { <p class="text-muted" style="font-size: 12px;">No HRs found</p> }
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Position *</label>
<input class="form-input" [(ngModel)]="newInterview.position" placeholder="e.g., Software Intern">
</div>
<div class="form-group">
<label class="form-label">Tech Stack</label>
<input class="form-input" [(ngModel)]="newInterview.techStack" placeholder="e.g., React, Node.js">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Source</label>
<input class="form-input" [(ngModel)]="newInterview.source" placeholder="e.g., LinkedIn, Campus">
</div>
<div class="form-group">
<label class="form-label">Date of Interview</label>
<input class="form-input" type="date" [(ngModel)]="newInterview.dateOfInterview">
</div>
</div>
<!-- Quiz Selection -->
<div class="form-group">
<label class="form-label">Assign Quizzes (optional)</label>
<div class="quiz-select-grid">
@for (q of quizzes(); track q._id) {
<div class="quiz-select-item" [class.selected]="isQuizSelected(q._id)" (click)="toggleQuizSelection(q._id)">
<span class="material-symbols-rounded">{{ isQuizSelected(q._id) ? 'check_box' : 'check_box_outline_blank' }}</span>
<span>{{ q.title }}</span>
</div>
}
@if (quizzes().length === 0) {
<p class="text-muted">No quizzes available</p>
}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeCreateModal()">Cancel</button>
<button class="btn btn-primary" (click)="createInterview()" [disabled]="isSubmitting() || !newInterview.candidateId || !newInterview.position">
@if (isSubmitting()) { <span class="spinner spinner-sm"></span> Creating... } @else { Create Interview }
</button>
</div>
</div>
</div>
}
<!-- Interview Detail Modal -->
@if (showDetailModal() && selectedInterview()) {
<div class="modal-overlay" (click)="closeDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div>
<h2>Interview Detail</h2>
<span class="badge" [ngClass]="getStatusClass(selectedInterview().status)">{{ formatStatus(selectedInterview().status) }}</span>
@if (selectedInterview().finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(selectedInterview().finalDecision)" style="margin-left:8px">{{ formatDecision(selectedInterview().finalDecision) }}</span>
}
</div>
<button class="icon-btn" (click)="closeDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Candidate Info -->
<div class="detail-section">
<h3 class="detail-section-title">Candidate Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Name</span>
<span class="detail-value">{{ selectedInterview().candidateId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{ selectedInterview().candidateId?.email }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedInterview().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedInterview().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Interviewer</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date of Interview</span>
<span class="detail-value">{{ selectedInterview().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedInterview().quizzes?.length > 0) {
<div class="detail-section">
<h3 class="detail-section-title">Quiz Results</h3>
<div class="quiz-results-grid">
@for (q of selectedInterview().quizzes; track q.quizId) {
<div class="quiz-result-card">
<div class="qr-title">{{ q.title }}</div>
@if (q.completed) {
<div class="qr-score">{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)</div>
} @else {
<div class="qr-pending">Not Taken</div>
}
</div>
}
</div>
</div>
}
<!-- Coding Round Submission -->
<div class="detail-section">
<h3 class="detail-section-title">Coding Challenge Submission</h3>
@if (selectedInterview().codingRound?.zipFile) {
<div class="eval-card" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span class="material-symbols-rounded" style="color: var(--accent-primary); vertical-align: middle; margin-right: 8px; font-size: 24px;">folder_zip</span>
<strong>Submitted on:</strong> {{ selectedInterview().codingRound.submittedAt | date:'medium' }}
</div>
<div style="display: flex; gap: 12px; align-items: center;">
@if (selectedInterview().codingRound.validated) {
<span class="badge badge-success">Validated by {{ selectedInterview().codingRound.validatedBy?.name || 'Reviewer' }}</span>
}
<a [href]="'http://localhost:5000' + selectedInterview().codingRound.zipFile" target="_blank" class="btn btn-primary" style="padding: 6px 12px; font-size: 14px; border-radius: 6px;">
<span class="material-symbols-rounded" style="font-size: 18px; margin-right: 4px;">download</span> Download
</a>
</div>
</div>
} @else {
<div class="quiz-results-grid">
<div class="quiz-result-card" style="border: 1px dashed var(--border-color); background: var(--bg-body);">
<div class="qr-title">Coding Challenge</div>
<div class="qr-pending">Not Submitted</div>
</div>
</div>
}
</div>
<!-- Evaluations -->
<div class="detail-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 class="detail-section-title" style="margin: 0;">Evaluations</h3>
@if (allEvaluationsDone()) {
<button class="btn btn-primary" (click)="downloadEvaluationPdf()">
@if (isPdfGenerating()) { <span class="spinner spinner-sm"></span> Generating... } @else { <span class="material-symbols-rounded">download</span> Download PDF }
</button>
}
</div>
@if (selectedInterview().evaluations?.length > 0) {
<div class="eval-list">
@for (ev of selectedInterview().evaluations; track ev._id) {
<div class="eval-card">
<div class="eval-header">
<div class="eval-evaluator">
<strong>{{ ev.evaluatorId?.name }}</strong>
<span class="eval-role badge badge-muted">{{ ev.evaluatorRole | uppercase }}</span>
</div>
<span class="badge" [ngClass]="getDecisionClass(ev.recommendation)">{{ formatDecision(ev.recommendation) }}</span>
</div>
@if (ev.comments) {
<p class="eval-comments">"{{ ev.comments }}"</p>
}
<span class="eval-date">{{ ev.date | date:'medium' }}</span>
</div>
}
</div>
} @else {
<p class="text-muted">No evaluations yet</p>
}
<!-- Add evaluation form -->
@if (!hasUserEvaluated() && selectedInterview().status !== 'completed') {
<div class="eval-form">
<h4>Add Your Evaluation</h4>
<div class="form-group">
<label class="form-label">Comments</label>
<textarea class="form-input form-textarea" [(ngModel)]="evalComment" placeholder="Enter your comments..." rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Recommendation *</label>
<select class="form-input" [(ngModel)]="evalRecommendation">
<option value="">Select recommendation</option>
<option value="offer">Offer / Hire as Intern</option>
<option value="on_hold">On Hold</option>
<option value="rejected">Rejected</option>
<option value="2nd_round">2nd Round</option>
</select>
</div>
<button class="btn btn-primary" (click)="submitEvaluation()" [disabled]="!evalRecommendation() || isSubmitting()">
Submit Evaluation
</button>
</div>
}
</div>
<!-- Final Decision (admin only) -->
@if (authService.getUserRole() === 'admin' && selectedInterview().status !== 'completed') {
<div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons">
<button class="btn btn-success" (click)="setDecision('accepted')">
<span class="material-symbols-rounded">check_circle</span> Accept
</button>
<button class="btn btn-warning" (click)="setDecision('on_hold')">
<span class="material-symbols-rounded">pause_circle</span> On Hold
</button>
<button class="btn btn-danger" (click)="setDecision('rejected')">
<span class="material-symbols-rounded">cancel</span> Reject
</button>
<button class="btn btn-outline" (click)="setDecision('2nd_round')">
<span class="material-symbols-rounded">replay</span> 2nd Round
</button>
</div>
</div>
}
</div>
<div class="modal-footer">
@if (authService.getUserRole() === 'admin') {
<button class="btn btn-danger btn-sm" (click)="deleteInterview(selectedInterview()._id)">Delete Interview</button>
}
<button class="btn btn-outline" (click)="closeDetail()">Close</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedInterview()) {
<div class="print-container">
<div class="print-header">
<div class="print-header-left">
<h2 style="margin: 0; font-size: 20px;">Intern Interview Evaluation Form</h2>
</div>
<div class="print-header-right" style="text-align: right;">
<span style="color: #0078d4; font-weight: bold; font-size: 24px;">IDEAL</span><br>
<span style="font-size: 10px; color: #555;">TECH LABS</span>
</div>
</div>
<table class="print-table" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Candidate Name:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedInterview().candidateId?.name }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Date of Interview:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedInterview().dateOfInterview | date:'mediumDate' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Position:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().position }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Source</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().source || '—' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Tech Stack:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().techStack || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Interviewer(s):</td>
<td style="border: 1px solid #000; padding: 8px;">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[0]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[0]?.completed ? selectedInterview().quizzes[0].score + '/' + selectedInterview().quizzes[0].totalMarks : 'Not Taken' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[1]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[1]?.completed ? selectedInterview().quizzes[1].score + '/' + selectedInterview().quizzes[1].totalMarks : 'Not Taken' }}</td>
</tr>
</table>
<!-- Render evaluations -->
@for (ev of selectedInterview().evaluations; track ev._id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ ev.comments || 'No comments provided.' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (ev.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + ev.evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ ev.date | date:'shortDate' }}</span>
</div>
</div>
</div>
}
</div>
}
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { QuizService } from '../../../services/quiz.service';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-individual-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css'
})
export class IndividualInterviewComponent implements OnInit {
interviews = signal<any[]>([]);
loading = signal(true);
showCreateModal = signal(false);
showDetailModal = signal(false);
selectedInterview = signal<any>(null);
// Create form data
candidates = signal<any[]>([]);
interviewers = signal<any[]>([]);
hrs = signal<any[]>([]);
pms = signal<any[]>([]);
quizzes = signal<any[]>([]);
newInterview = {
candidateId: '',
assignedInterviewers: [] as string[],
assignedHRs: [] as string[],
assignedPMs: [] as string[],
position: '',
techStack: '',
source: '',
dateOfInterview: new Date().toISOString().split('T')[0],
quizIds: [] as string[]
};
// Evaluation form
evalComment = signal('');
evalRecommendation = signal('');
// Stats
stats = signal<any>({ total: 0, pending: 0, completed: 0, accepted: 0, rejected: 0 });
// Filter
filterStatus = signal('');
isSubmitting = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
ngOnInit(): void {
this.loadInterviews();
this.loadStats();
}
loadInterviews(): void {
this.loading.set(true);
const params: any = {};
if (this.filterStatus()) params.status = this.filterStatus();
params.type = 'individual';
this.quizService.getInterviews(params).subscribe({
next: (res) => {
this.interviews.set(res.interviews || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
loadStats(): void {
this.quizService.getInterviewStats().subscribe({
next: (res) => this.stats.set(res)
});
}
openCreateModal(): void {
// Load dropdown data
this.quizService.getInterviewCandidates().subscribe({
next: (res) => this.candidates.set(res.candidates || [])
});
this.quizService.getInterviewers().subscribe({
next: (res) => {
const staff = res.interviewers || [];
this.interviewers.set(staff.filter((s: any) => s.role === 'interviewer'));
this.pms.set(staff.filter((s: any) => s.role === 'pm'));
this.hrs.set(staff.filter((s: any) => s.role === 'hr'));
}
});
this.quizService.getAdminQuizzes().subscribe({
next: (res) => this.quizzes.set(res.quizzes || [])
});
this.newInterview = {
candidateId: '', assignedInterviewers: [], assignedHRs: [], assignedPMs: [], position: '', techStack: '',
source: '', dateOfInterview: new Date().toISOString().split('T')[0], quizIds: []
};
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
}
toggleQuizSelection(quizId: string): void {
const idx = this.newInterview.quizIds.indexOf(quizId);
if (idx >= 0) {
this.newInterview.quizIds.splice(idx, 1);
} else {
this.newInterview.quizIds.push(quizId);
}
}
toggleSelection(event: any, array: string[]): void {
const val = event.target.value;
if (event.target.checked) {
array.push(val);
} else {
const idx = array.indexOf(val);
if (idx >= 0) array.splice(idx, 1);
}
}
isQuizSelected(quizId: string): boolean {
return this.newInterview.quizIds.includes(quizId);
}
createInterview(): void {
if (!this.newInterview.candidateId || !this.newInterview.position) return;
this.isSubmitting.set(true);
this.quizService.createInterview(this.newInterview).subscribe({
next: () => {
this.isSubmitting.set(false);
this.closeCreateModal();
this.loadInterviews();
this.loadStats();
},
error: (err) => {
this.isSubmitting.set(false);
alert(err.error?.message || 'Failed to create interview');
}
});
}
openDetail(interview: any): void {
this.quizService.getInterviewById(interview._id).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.evalComment.set('');
this.evalRecommendation.set('');
this.showDetailModal.set(true);
}
});
}
closeDetail(): void {
this.showDetailModal.set(false);
this.selectedInterview.set(null);
}
submitEvaluation(): void {
const interview = this.selectedInterview();
if (!interview || !this.evalRecommendation()) return;
this.isSubmitting.set(true);
this.quizService.submitEvaluation(interview._id, {
comments: this.evalComment(),
recommendation: this.evalRecommendation()
}).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.evalComment.set('');
this.evalRecommendation.set('');
this.isSubmitting.set(false);
},
error: () => this.isSubmitting.set(false)
});
}
setDecision(decision: string): void {
const interview = this.selectedInterview();
if (!interview) return;
this.quizService.setInterviewDecision(interview._id, decision).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.loadInterviews();
this.loadStats();
}
});
}
deleteInterview(id: string): void {
if (confirm('Are you sure you want to delete this interview?')) {
this.quizService.deleteInterview(id).subscribe({
next: () => {
this.loadInterviews();
this.loadStats();
this.closeDetail();
}
});
}
}
onFilterChange(): void {
this.loadInterviews();
}
getStatusClass(status: string): string {
switch (status) {
case 'pending': return 'badge-warning';
case 'quiz_phase': return 'badge-info';
case 'coding_phase': return 'badge-info';
case 'evaluation': return 'badge-purple';
case 'completed': return 'badge-success';
default: return '';
}
}
getDecisionClass(decision: string): string {
switch (decision) {
case 'accepted': return 'badge-success';
case 'rejected': return 'badge-danger';
case 'on_hold': return 'badge-warning';
case '2nd_round': return 'badge-info';
default: return 'badge-muted';
}
}
formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
formatDecision(decision: string): string {
const map: any = {
pending: 'Pending', accepted: 'Accepted', rejected: 'Rejected',
on_hold: 'On Hold', '2nd_round': '2nd Round'
};
return map[decision] || decision;
}
hasUserEvaluated(): boolean {
const interview = this.selectedInterview();
if (!interview) return false;
const userId = this.authService.currentUser()?.id;
return interview.evaluations?.some((e: any) => e.evaluatorId?._id === userId);
}
isPdfGenerating = signal(false);
allEvaluationsDone(): boolean {
const iv = this.selectedInterview();
if (!iv) return false;
const numInterviewers = iv.assignedInterviewers?.length || 0;
const numHrs = iv.assignedHRs?.length || 0;
const numPms = iv.assignedPMs?.length || 0;
const totalExpected = numInterviewers + numHrs + numPms;
if (totalExpected === 0) return false;
const numEvaluations = iv.evaluations?.length || 0;
return numEvaluations >= totalExpected;
}
downloadEvaluationPdf(): void {
const iv = this.selectedInterview();
if (!iv) return;
this.isPdfGenerating.set(true);
setTimeout(() => {
window.print();
this.isPdfGenerating.set(false);
}, 500);
}
}
<div class="page-container animate-fade-in split-view" cdkDropListGroup> <div class="page-container animate-fade-in split-view" cdkDropListGroup>
<div class="main-workspace"> <div class="main-workspace">
<div class="page-header"> <div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<h1>Manage Groups</h1> <button class="icon-btn" (click)="goBack()" style="background: var(--bg-card); border: 1px solid var(--border-color); padding: 8px; border-radius: 12px; cursor: pointer; color: var(--text-primary); transition: all 0.2s; display: flex; align-items: center; justify-content: center;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='var(--bg-card)'">
<p class="page-subtitle">Create organizational groups and effortlessly drag-and-drop candidates into them.</p> <span class="material-symbols-rounded">arrow_back</span>
</button>
<div>
<h1 style="margin: 0; font-size: 24px;">Manage Groups</h1>
<p class="page-subtitle" style="margin: 4px 0 0;">Create organizational groups and effortlessly drag-and-drop candidates into them.</p>
</div>
</div> </div>
@if (error()) { @if (error()) {
......
import { Component, signal, OnInit, computed } from '@angular/core'; import { Component, signal, OnInit, computed } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop'; import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
...@@ -31,7 +31,11 @@ export class ManageGroupsComponent implements OnInit { ...@@ -31,7 +31,11 @@ export class ManageGroupsComponent implements OnInit {
return this.allStudents().filter(s => !s.group || s.group === 'General'); return this.allStudents().filter(s => !s.group || s.group === 'General');
}); });
constructor(private quizService: QuizService) {} constructor(private quizService: QuizService, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void { ngOnInit(): void {
this.loadGroups(); this.loadGroups();
......
...@@ -3,10 +3,212 @@ ...@@ -3,10 +3,212 @@
max-width: 1400px; max-width: 1400px;
} }
/* ========== BREADCRUMB ========== */
.breadcrumb { .breadcrumb {
margin-bottom: 24px; margin-bottom: 24px;
} }
.breadcrumb-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.breadcrumb-link:hover { color: var(--accent-hover); }
.breadcrumb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
color: var(--accent-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.breadcrumb-btn:hover { color: var(--accent-hover); }
/* ========== SECTION HEADER ========== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
margin: 0;
}
.section-icon {
color: var(--accent-primary);
font-size: 22px;
}
.section-label-sm {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin: 0 0 4px;
}
/* ========== INTERVIEW COUNT BADGE ========== */
.interview-count-badge {
background: rgba(102,126,234,0.12);
color: var(--accent-primary);
padding: 4px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
/* ========== TABLE: INTERVIEW-SPECIFIC ELEMENTS ========== */
.interview-row {
cursor: pointer;
transition: background 0.15s;
}
.interview-row:hover { background: var(--bg-hover); }
.interview-seq {
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
background: var(--bg-hover);
padding: 4px 10px;
border-radius: 6px;
}
.interview-position {
font-weight: 600;
color: var(--text-primary);
}
.interviewer-cell {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.interviewer-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quiz-count-badge {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
/* ========== STATUS BADGE ========== */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-badge-lg {
font-size: 14px;
padding: 8px 16px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.status-pending {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.status-evaluation {
background: rgba(99, 102, 241, 0.12);
color: #6366f1;
}
.status-done {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
/* ========== QUIZ ID CODE BADGE ========== */
.quiz-id-badge {
font-family: 'Courier New', monospace;
font-size: 11px;
font-weight: 700;
background: rgba(102,126,234,0.1);
color: var(--accent-primary);
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
letter-spacing: 0.3px;
}
/* ========== DIFFICULTY BADGE ========== */
.diff-badge {
padding: 3px 10px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.diff-easy { background: rgba(34,197,94,0.12); color: #16a34a; }
.diff-medium { background: rgba(245,158,11,0.12); color: #d97706; }
.diff-hard { background: rgba(239,68,68,0.12); color: #dc2626; }
/* ========== NOT TAKEN ========== */
.not-taken {
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
.date-cell { color: var(--text-secondary); font-size: 13px; }
.breadcrumb a { .breadcrumb a {
color: #667eea; color: #667eea;
text-decoration: none; text-decoration: none;
......
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="content-wrapper"> <div class="content-wrapper">
<!-- Breadcrumb -->
<div class="breadcrumb"> <div class="breadcrumb">
<a routerLink="/admin/users">← Back to Users</a> @if (selectedInterview()) {
<button class="breadcrumb-btn" (click)="goBackToList()">
<span class="material-symbols-rounded">arrow_back</span> Back to Interviews
</button>
} @else {
<a routerLink="/admin/users" class="breadcrumb-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Users
</a>
}
</div> </div>
@if (loading()) { @if (loadingInterviews()) {
<div class="loading-state"> <div class="loading-state">
<div class="loader"></div> <div class="loader"></div>
<p>Loading history...</p> <p>Loading candidate data...</p>
</div> </div>
} @else { } @else {
<!-- ========== CANDIDATE HEADER CARD ========== -->
@if (user()) { @if (user()) {
<div class="student-header"> <div class="student-header">
<div class="student-profile"> <div class="student-profile">
...@@ -26,7 +38,6 @@ ...@@ -26,7 +38,6 @@
</span> </span>
} }
</div> </div>
@if (user().resume) { @if (user().resume) {
<div class="student-resume-info"> <div class="student-resume-info">
<a [href]="getResumeUrl(user().resume)" target="_blank" class="resume-badge"> <a [href]="getResumeUrl(user().resume)" target="_blank" class="resume-badge">
...@@ -54,22 +65,126 @@ ...@@ -54,22 +65,126 @@
</div> </div>
} }
<h2 class="section-title">Test History</h2> <!-- ========== INTERVIEW LIST VIEW ========== -->
@if (!selectedInterview()) {
<div class="section-header">
<h2 class="section-title">
<span class="material-symbols-rounded section-icon">work_history</span>
Interview History
</h2>
<span class="interview-count-badge">{{ interviews().length }} Interview{{ interviews().length !== 1 ? 's' : '' }}</span>
</div>
@if (interviews().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">event_busy</span>
<h3>No interviews found</h3>
<p>This candidate hasn't been assigned any interviews yet.</p>
</div>
} @else {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>#</th>
<th>Position</th>
<th>Interviewer</th>
<th>Quizzes</th>
<th>Status</th>
<th>Date</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (interview of interviews(); track interview._id; let i = $index) {
<tr class="interview-row">
<td>
<span class="interview-seq">INT-{{ String(i + 1).padStart(3, '0') }}</span>
</td>
<td class="interview-position">{{ interview.position || '—' }}</td>
<td>
<div class="interviewer-cell">
<div class="interviewer-avatar">{{ interview.interviewerId?.name?.charAt(0) || '?' }}</div>
<span>{{ interview.interviewerId?.name || 'Unassigned' }}</span>
</div>
</td>
<td>
<span class="quiz-count-badge">{{ (interview.quizzes || []).length }} Quiz{{ (interview.quizzes || []).length !== 1 ? 'zes' : '' }}</span>
</td>
<td>
<span class="status-badge" [ngClass]="getStatusClass(interview.status)">
<span class="status-dot"></span>
{{ getStatusLabel(interview.status) }}
</span>
</td>
<td class="date-cell">{{ interview.dateOfInterview | date:'dd MMM yyyy' }}</td>
<td>
<button class="view-btn" (click)="selectInterview(interview)">
View Quizzes <span class="material-symbols-rounded" style="font-size:16px;vertical-align:middle;">arrow_forward</span>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
} @else {
<!-- ========== INTERVIEW DETAIL / QUIZ DRILL-DOWN ========== -->
<div class="interview-detail-header card card-padding" style="margin-bottom: 32px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px;">
<div>
<p class="section-label-sm">Interview Details</p>
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: var(--text-primary);">{{ selectedInterview().position }}</h2>
<div class="student-contact-info" style="margin-top: 8px;">
<span class="contact-item">
<span class="material-symbols-rounded">person</span> {{ selectedInterview().interviewerId?.name || 'Unassigned' }}
</span>
<span class="contact-item">
<span class="material-symbols-rounded">calendar_today</span> {{ selectedInterview().dateOfInterview | date:'dd MMM yyyy' }}
</span>
<span class="contact-item">
<span class="material-symbols-rounded">source</span> {{ selectedInterview().source || 'N/A' }}
</span>
</div>
</div>
<span class="status-badge status-badge-lg" [ngClass]="getStatusClass(selectedInterview().status)">
<span class="status-dot"></span>
{{ getStatusLabel(selectedInterview().status) }}
</span>
</div>
</div>
<!-- Quiz table within interview -->
<div class="section-header">
<h2 class="section-title">
<span class="material-symbols-rounded section-icon">quiz</span>
Assessment Rounds
</h2>
</div>
@if (submissions().length === 0) { @if (loadingSubmissions()) {
<div class="loading-state">
<div class="loader"></div>
<p>Loading quiz results...</p>
</div>
} @else if ((selectedInterview().quizzes || []).length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="empty-icon">📋</span> <span class="material-symbols-rounded empty-icon">assignment</span>
<h3>No tests taken yet</h3> <h3>No quizzes assigned</h3>
<p>This student hasn't taken any quizzes.</p> <p>No assessment rounds were linked to this interview.</p>
</div> </div>
} @else { } @else {
<div class="history-table-wrap"> <div class="history-table-wrap">
<table class="history-table"> <table class="history-table">
<thead> <thead>
<tr> <tr>
<th>Quiz ID</th>
<th>Quiz Name</th> <th>Quiz Name</th>
<th>Topic</th> <th>Topic</th>
<th>Candidate's comfort Level</th> <th>Level</th>
<th>Comfort Level</th>
<th>Score</th> <th>Score</th>
<th>Percentage</th> <th>Percentage</th>
<th>Time Taken</th> <th>Time Taken</th>
...@@ -78,13 +193,33 @@ ...@@ -78,13 +193,33 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (sub of submissions(); track sub._id) { @for (iq of selectedInterview().quizzes; track iq._id; let i = $index) {
<tr> <tr>
<td class="quiz-name">{{ sub.quizId?.title || 'Deleted Quiz' }}</td>
<td>{{ sub.quizId?.category || 'N/A' }}</td>
<td>{{ getComfortLevel(sub.quizId?.category) }}</td>
<td><span class="score-badge">{{ sub.score }}/{{ sub.totalMarks }}</span></td>
<td> <td>
<code class="quiz-id-badge">{{ getQuizId(iq, i) }}</code>
</td>
<td class="quiz-name">{{ iq.quizId?.title || iq.title || 'Quiz' }}</td>
<td>{{ iq.quizId?.category || 'N/A' }}</td>
<td>
<span class="diff-badge"
[class.diff-easy]="(iq.quizId?.difficulty || '').toLowerCase() === 'easy'"
[class.diff-medium]="(iq.quizId?.difficulty || '').toLowerCase() === 'medium'"
[class.diff-hard]="(iq.quizId?.difficulty || '').toLowerCase() === 'hard'">
{{ iq.quizId?.difficulty | uppercase }}
</span>
</td>
<td>{{ getComfortLevel(iq.quizId?.category) }}</td>
<td>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
<span class="score-badge">{{ sub.score }}/{{ sub.totalMarks }}</span>
} @else if (iq.score !== null && iq.score !== undefined) {
<span class="score-badge">{{ iq.score }}/{{ iq.totalMarks }}</span>
} @else {
<span class="not-taken">Not Taken</span>
}
</td>
<td>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
<div class="percent-bar"> <div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage" <div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70" [class.good]="sub.percentage >= 70"
...@@ -92,11 +227,36 @@ ...@@ -92,11 +227,36 @@
[class.poor]="sub.percentage < 40"></div> [class.poor]="sub.percentage < 40"></div>
</div> </div>
<span class="percent-text">{{ sub.percentage }}%</span> <span class="percent-text">{{ sub.percentage }}%</span>
} @else if (iq.percentage !== null && iq.percentage !== undefined) {
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="iq.percentage"
[class.good]="iq.percentage >= 70"
[class.avg]="iq.percentage >= 40 && iq.percentage < 70"
[class.poor]="iq.percentage < 40"></div>
</div>
<span class="percent-text">{{ iq.percentage }}%</span>
} @else {
<span class="not-taken"></span>
}
</td> </td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'medium' }}</td>
<td> <td>
<a [routerLink]="['/admin/submissions', sub._id]" class="view-btn">View Details</a> @if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
{{ formatTime(sub.timeTaken) }}
} @else { — }
</td>
<td class="date-cell">
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
{{ sub.submittedAt | date:'dd MMM yyyy' }}
} @else { — }
</td>
<td>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
<a [routerLink]="['/admin/submissions', sub._id]" class="view-btn">
Review <span class="material-symbols-rounded" style="font-size:16px;vertical-align:middle;">open_in_new</span>
</a>
} @else {
<span class="not-taken">Pending</span>
}
</td> </td>
</tr> </tr>
} }
...@@ -105,6 +265,7 @@ ...@@ -105,6 +265,7 @@
</div> </div>
} }
} }
}
</div> </div>
</div> </div>
......
...@@ -14,11 +14,20 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -14,11 +14,20 @@ import { QuizService } from '../../../services/quiz.service';
export class UserHistoryComponent implements OnInit { export class UserHistoryComponent implements OnInit {
userId = ''; userId = '';
user = signal<any>(null); user = signal<any>(null);
interviews = signal<any[]>([]);
// The interview the user has drilled into
selectedInterview = signal<any>(null);
// Submissions for the selected interview's quizzes
submissions = signal<any[]>([]); submissions = signal<any[]>([]);
loading = signal<boolean>(true); loadingInterviews = signal<boolean>(true);
loadingSubmissions = signal<boolean>(false);
currentLevel = signal<string>('beginner'); currentLevel = signal<string>('beginner');
toast = signal<{ message: string; type: 'success' | 'error' } | null>(null); toast = signal<{ message: string; type: 'success' | 'error' } | null>(null);
// Expose String for template
readonly String = String;
levels = [ levels = [
{ value: 'beginner', label: 'Fresher' }, { value: 'beginner', label: 'Fresher' },
{ value: 'intermediate', label: 'Intern' }, { value: 'intermediate', label: 'Intern' },
...@@ -26,6 +35,13 @@ export class UserHistoryComponent implements OnInit { ...@@ -26,6 +35,13 @@ export class UserHistoryComponent implements OnInit {
{ value: 'expert', label: 'Expert' } { value: 'expert', label: 'Expert' }
]; ];
/** Difficulty → level code mapping */
private levelCodeMap: Record<string, string> = {
easy: 'BEG',
medium: 'INT',
hard: 'ADV'
};
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
public authService: AuthService, public authService: AuthService,
...@@ -34,28 +50,63 @@ export class UserHistoryComponent implements OnInit { ...@@ -34,28 +50,63 @@ export class UserHistoryComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.userId = this.route.snapshot.params['userId']; this.userId = this.route.snapshot.params['userId'];
this.loadHistory(); this.loadInterviews();
} }
loadHistory(): void { loadInterviews(): void {
this.quizService.getUserHistory(this.userId).subscribe({ this.loadingInterviews.set(true);
this.quizService.getUserInterviews(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.currentLevel.set(res.user.level || 'beginner');
this.submissions.set(res.submissions); this.interviews.set(res.interviews);
this.loading.set(false); this.loadingInterviews.set(false);
}, },
error: () => this.loading.set(false) error: () => this.loadingInterviews.set(false)
}); });
} }
selectInterview(interview: any): void {
this.selectedInterview.set(interview);
// Load submissions for the quizzes in this interview
this.loadingSubmissions.set(true);
this.quizService.getUserHistory(this.userId).subscribe({
next: (res) => {
this.submissions.set(res.submissions || []);
this.loadingSubmissions.set(false);
},
error: () => this.loadingSubmissions.set(false)
});
}
goBackToList(): void {
this.selectedInterview.set(null);
this.submissions.set([]);
}
/** Find submission for a given quizId from the loaded submissions */
getSubmissionForQuiz(quizId: any): any {
if (!quizId) return null;
const qidStr = quizId.toString();
return this.submissions().find((s: any) =>
(s.quizId?._id?.toString() || s.quizId?.toString()) === qidStr
) || null;
}
/** Generate a quiz ID like Q_Aptitude_BEG_001 for a given quiz within an interview */
getQuizId(interviewQuiz: any, index: number): string {
const quizData = interviewQuiz.quizId || {};
const topic = (quizData.category || 'General').replace(/\s+/g, '_');
const diff = (quizData.difficulty || 'easy').toLowerCase();
const levelCode = this.levelCodeMap[diff] || 'BEG';
const seq = String(index + 1).padStart(3, '0');
return `Q_${topic}_${levelCode}_${seq}`;
}
changeLevel(newLevel: string): void { changeLevel(newLevel: string): void {
if (newLevel === this.currentLevel()) return; if (newLevel === this.currentLevel()) return;
const previousLevel = this.currentLevel(); this.quizService.updateUserLevel(this.userId, newLevel).subscribe({
const userRole = this.authService.currentUser()?.role || 'admin';
this.quizService.updateUserLevel(this.userId, newLevel, userRole).subscribe({
next: (res) => { next: (res) => {
this.currentLevel.set(newLevel); this.currentLevel.set(newLevel);
this.showToast( this.showToast(
...@@ -69,6 +120,24 @@ export class UserHistoryComponent implements OnInit { ...@@ -69,6 +120,24 @@ export class UserHistoryComponent implements OnInit {
}); });
} }
getStatusClass(status: string): string {
switch (status) {
case 'completed': return 'status-done';
case 'evaluation': return 'status-evaluation';
default: return 'status-pending';
}
}
getStatusLabel(status: string): string {
switch (status) {
case 'completed': return 'Done';
case 'evaluation': return 'Evaluation';
case 'coding_phase': return 'Coding Round';
case 'quiz_phase': return 'Quiz Phase';
default: return 'Pending';
}
}
private showToast(message: string, type: 'success' | 'error'): void { private showToast(message: string, type: 'success' | 'error'): void {
this.toast.set({ message, type }); this.toast.set({ message, type });
setTimeout(() => this.toast.set(null), 3500); setTimeout(() => this.toast.set(null), 3500);
...@@ -87,15 +156,10 @@ export class UserHistoryComponent implements OnInit { ...@@ -87,15 +156,10 @@ export class UserHistoryComponent implements OnInit {
getComfortLevel(topic: string): string { getComfortLevel(topic: string): string {
const u = this.user(); const u = this.user();
if (!u || !u.topicsOfInterest || !topic) return 'N/A'; if (!u || !u.topicsOfInterest || !topic) return 'N/A';
const interest = u.topicsOfInterest.find((t: any) => t.topic.toLowerCase() === topic.toLowerCase()); const interest = u.topicsOfInterest.find((t: any) => t.topic.toLowerCase() === topic.toLowerCase());
return interest ? `${interest.comfortLevel}%` : 'N/A'; return interest ? `${interest.comfortLevel}%` : 'N/A';
} }
logout(): void {
this.authService.logout();
}
getResumeUrl(resumePath: string): string { getResumeUrl(resumePath: string): string {
if (!resumePath) return ''; if (!resumePath) return '';
return `http://localhost:5000${resumePath}`; return `http://localhost:5000${resumePath}`;
......
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="page-header"> <div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<h1>Student Users</h1> <button class="icon-btn" (click)="goBack()" style="background: var(--bg-card); border: 1px solid var(--border-color); padding: 8px; border-radius: 12px; cursor: pointer; color: var(--text-primary); transition: all 0.2s; display: flex; align-items: center; justify-content: center;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='var(--bg-card)'">
<p>View and manage registered students</p> <span class="material-symbols-rounded">arrow_back</span>
</button>
<div>
<h1 style="margin: 0; font-size: 24px;">Student Users</h1>
<p style="margin: 4px 0 0;" class="page-subtitle">View and manage registered students</p>
</div>
</div> </div>
<div class="filter-tabs"> <div class="filter-tabs">
......
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
...@@ -16,7 +17,11 @@ export class AdminUsersComponent implements OnInit { ...@@ -16,7 +17,11 @@ export class AdminUsersComponent implements OnInit {
loading = signal<boolean>(true); loading = signal<boolean>(true);
showAll = signal<boolean>(true); showAll = signal<boolean>(true);
constructor(public authService: AuthService, private quizService: QuizService) {} constructor(public authService: AuthService, private quizService: QuizService, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void { ngOnInit(): void {
this.loadUsers(); this.loadUsers();
......
...@@ -3,83 +3,181 @@ ...@@ -3,83 +3,181 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Welcome, {{ authService.currentUser()?.name }}!</h1> <h1>Welcome, {{ authService.currentUser()?.name }}!</h1>
<p class="page-subtitle">Your assigned quizzes are listed below</p> <p class="page-subtitle">Your assigned interviews and assessments</p>
</div> </div>
</div> </div>
@if (loading()) { @if (loading()) {
<div class="loading-center"> <div class="loading-center">
<div class="spinner spinner-lg"></div> <div class="spinner spinner-lg"></div>
<p class="loading-text">Loading quizzes...</p> <p class="loading-text">Loading interviews...</p>
</div> </div>
} @else if (quizzes().length === 0) { } @else if (interviews().length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="material-symbols-rounded">assignment</span> <span class="material-symbols-rounded">event_busy</span>
<h3>No quizzes assigned</h3> <h3>No interviews assigned</h3>
<p>Check back later for new assessments</p> <p>Check back later for new opportunities</p>
</div> </div>
} @else { } @else {
<div class="quiz-grid stagger-children"> <div class="interview-list stagger-children">
@for (quiz of quizzes(); track quiz.id) { @for (interview of interviews(); track interview._id) {
<div class="quiz-card card" [class.taken]="quiz.taken"> <div class="interview-card card" style="padding: 32px; margin-bottom: 24px;">
<div class="quiz-card-header"> <!-- Header Area -->
<div class="quiz-icon-wrap" [class.completed]="quiz.taken"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<span class="material-symbols-rounded">{{ quiz.taken ? 'task_alt' : 'description' }}</span> <div>
</div> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<div class="quiz-meta"> <h2 style="margin: 0; font-size: 28px; font-weight: 700; color: var(--text-primary);">{{ interview.position || 'Software Engineering Interview' }}</h2>
@if (quiz.category) { </div>
<span class="badge badge-primary" title="{{ quiz.category }}">{{ quiz.category | uppercase }}</span> <p style="margin: 0; color: var(--text-muted); font-size: 15px;">{{ interview.source || 'General Recruitment' }} • Tech Stack: {{ interview.techStack || 'Various' }}</p>
<div style="display: flex; align-items: center; gap: 16px; margin-top: 20px;">
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-body); border-radius: 8px; border: 1px solid var(--border-color);">
<span class="material-symbols-rounded" style="color: var(--text-muted); font-size: 20px;">groups</span>
<span style="font-size: 14px; font-weight: 500;">Individual Participation</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--bg-body); border-radius: 8px; border: 1px solid var(--border-color);">
<span class="material-symbols-rounded" style="color: var(--text-muted); font-size: 20px;">quiz</span>
<span style="font-size: 14px; font-weight: 500;">Quizzes & Technical</span>
</div>
</div>
</div>
<div style="padding: 16px; background: var(--bg-body); border-radius: 12px; border: 1px solid var(--border-color); text-align: center;">
<div style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">Interview Date</div>
<div style="font-size: 16px; font-weight: 600; color: var(--accent-primary);">{{ interview.dateOfInterview | date:'dd MMM yyyy' }}</div>
</div>
</div>
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 32px 0;">
<!-- Eligibility -->
<div style="margin-bottom: 32px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<div style="width: 4px; height: 24px; background: var(--accent-primary); border-radius: 4px;"></div>
<h3 style="margin: 0; font-size: 20px; font-weight: 600;">Eligibility</h3>
</div>
<p style="margin: 0; color: var(--text-muted); line-height: 1.6; padding-left: 16px;">
Engineering Students • Postgraduate • Undergraduate • Management • Sciences & Others
</p>
</div>
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 32px 0;">
<!-- Stages and Timelines -->
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
<div style="width: 4px; height: 24px; background: var(--accent-primary); border-radius: 4px;"></div>
<h3 style="margin: 0; font-size: 20px; font-weight: 600;">Stages and Timelines</h3>
</div>
@if (interview.quizzes && interview.quizzes.length > 0) {
<div style="display: flex; flex-direction: column; gap: 24px; padding-left: 16px;">
@for (q of interview.quizzes; track q._id; let i = $index) {
<div style="display: flex; gap: 16px;">
<!-- Timeline Marker -->
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; width: 48px;">
<div style="width: 48px; height: 48px; border-radius: 12px; background: var(--bg-body); border: 1px solid var(--border-color); display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="font-size: 14px; font-weight: 700; color: var(--text-primary);">Round</span>
<span style="font-size: 12px; font-weight: 600; color: var(--accent-primary);">0{{ i + 1 }}</span>
</div>
<div style="width: 2px; flex: 1; background: var(--border-color); min-height: 40px;"></div>
</div>
<!-- Stage Card -->
<div class="card" style="flex: 1; border: 1px solid var(--border-color); border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.03);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4 style="margin: 0; font-size: 18px; font-weight: 600;">{{ q.title || q.quizId?.title }}</h4>
<div style="display: flex; gap: 8px;">
@if (q.quizId?.category) {
<span class="badge badge-primary">{{ q.quizId?.category | uppercase }}</span>
} }
@if (quiz.difficulty) { @if (q.quizId?.difficulty) {
<span class="badge difficulty-badge" [ngClass]="{ <span class="badge" [ngClass]="{
'badge-success': quiz.difficulty.toLowerCase() === 'easy', 'badge-success': q.quizId?.difficulty?.toLowerCase() === 'easy',
'badge-warning': quiz.difficulty.toLowerCase() === 'medium', 'badge-warning': q.quizId?.difficulty?.toLowerCase() === 'medium',
'badge-danger': quiz.difficulty.toLowerCase() === 'hard' 'badge-danger': q.quizId?.difficulty?.toLowerCase() === 'hard'
}">{{ quiz.difficulty | uppercase }}</span> }">{{ q.quizId?.difficulty | uppercase }}</span>
} }
</div> </div>
</div> </div>
<h3 class="quiz-title">{{ quiz.title }}</h3>
<div class="quiz-details"> <ul style="margin: 0 0 24px 0; padding-left: 20px; color: var(--text-muted); line-height: 1.6;">
<span class="quiz-detail"> <li>In this round, you'll take a structured online assessment consisting of {{ q.quizId?.totalQuestions }} multiple-choice questions.</li>
<span class="material-symbols-rounded">timer</span> <li>This evaluates your technical knowledge, analytical thinking, and job-relevant skills.</li>
{{ quiz.timer }} min <li>You will have {{ q.quizId?.timer }} minutes to complete this assessment.</li>
</span> </ul>
<span class="quiz-detail">
<span class="material-symbols-rounded">help</span> <div style="display: flex; justify-content: flex-end; align-items: center;">
{{ quiz.totalQuestions }} questions @if (q.completed || q.score !== null) {
</span> <div style="display: flex; align-items: center; gap: 16px;">
</div> <!-- Score hidden for candidate -->
<div style="padding: 8px 16px; background: rgba(34, 197, 94, 0.1); color: #22c55e; border-radius: 8px; font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 6px;">
@if (quiz.taken) { <span class="material-symbols-rounded" style="font-size: 18px;">check_circle</span> Completed
<div class="quiz-result"> </div>
<div class="result-bar-track">
<div class="result-bar-fill"
[style.width.%]="quiz.percentage"
[ngClass]="{
'fill-good': quiz.percentage >= 70,
'fill-avg': quiz.percentage >= 40 && quiz.percentage < 70,
'fill-poor': quiz.percentage < 40
}"></div>
</div>
<span class="result-score">Score: {{ quiz.score }}/{{ quiz.totalMarks }} ({{ quiz.percentage }}%)</span>
</div>
<div class="quiz-card-footer">
<span class="badge badge-success">
<span class="material-symbols-rounded" style="font-size: 14px">check</span>
Completed
</span>
</div> </div>
} @else { } @else if (i === 0 || interview.quizzes[i-1].completed) {
<div class="quiz-card-footer"> <a [routerLink]="['/candidate/quiz', q.quizId?._id || q.quizId]" class="btn btn-primary" style="padding: 10px 24px; border-radius: 8px; font-weight: 600;">
<a [routerLink]="['/candidate/quiz', quiz.id]" class="btn btn-primary btn-sm"> Start Assessment
Start Quiz
<span class="material-symbols-rounded" style="font-size: 16px">arrow_forward</span>
</a> </a>
}
</div> </div>
</div>
</div>
}
<!-- Coding Round Stage -->
<div style="display: flex; gap: 16px;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; width: 48px;">
<div style="width: 48px; height: 48px; border-radius: 12px; background: var(--bg-body); border: 1px solid var(--border-color); display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="font-size: 14px; font-weight: 700; color: var(--text-primary);">Round</span>
<span style="font-size: 12px; font-weight: 600; color: var(--accent-primary);">0{{ interview.quizzes ? interview.quizzes.length + 1 : 1 }}</span>
</div>
</div>
<div class="card" style="flex: 1; border: 1px solid var(--border-color); border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.03);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4 style="margin: 0; font-size: 18px; font-weight: 600;">Coding Challenge</h4>
<span class="badge badge-warning">PRACTICAL</span>
</div>
<ul style="margin: 0 0 24px 0; padding-left: 20px; color: var(--text-muted); line-height: 1.6;">
<li>In this round, you need to complete the coding challenge provided in the interview brief.</li>
<li>Submit a ZIP file containing your code, output file (.txt), and screenshots of test cases.</li>
<li>Maximum file size is 25MB.</li>
</ul>
<div style="display: flex; justify-content: flex-end; align-items: center;">
@if (interview.codingRound?.zipFile) {
<div style="display: flex; align-items: center; gap: 16px;">
<div style="padding: 8px 16px; background: rgba(34, 197, 94, 0.1); color: #22c55e; border-radius: 8px; font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 6px;">
<span class="material-symbols-rounded" style="font-size: 18px;">check_circle</span> Submitted
</div>
</div>
} @else if (!interview.quizzes || interview.quizzes.length === 0 || interview.quizzes[interview.quizzes.length - 1].completed) {
<div style="display: flex; gap: 12px; align-items: center;">
@if (uploadingCodingId() === interview._id) {
<span class="spinner spinner-sm" style="width: 20px; height: 20px; border: 2px solid var(--accent-primary); border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></span> <span style="font-weight: 600;">Uploading...</span>
} @else {
<label class="btn btn-primary" style="padding: 10px 24px; border-radius: 8px; font-weight: 600; cursor: pointer; margin: 0;">
Upload ZIP
<input type="file" style="display: none" accept=".zip,.rar" (change)="onCodingZipSelected($event, interview._id)">
</label>
} }
</div> </div>
} }
</div> </div>
</div>
</div>
</div>
} @else {
<div style="padding: 24px; background: var(--bg-body); border-radius: 12px; border: 1px dashed var(--border-color); text-align: center; color: var(--text-muted);">
No assessment stages assigned to this interview yet.
</div>
}
</div>
</div>
}
</div>
} }
</div> </div>
...@@ -12,18 +12,41 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -12,18 +12,41 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './dashboard.css' styleUrl: './dashboard.css'
}) })
export class CandidateDashboardComponent implements OnInit { export class CandidateDashboardComponent implements OnInit {
quizzes = signal<any[]>([]); interviews = signal<any[]>([]);
loading = signal(true); loading = signal(true);
uploadingCodingId = signal<string | null>(null);
constructor(public authService: AuthService, private quizService: QuizService) {} constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void { ngOnInit(): void {
this.quizService.getAvailableQuizzes().subscribe({ this.loadInterviews();
}
loadInterviews(): void {
this.quizService.getCandidateInterviews().subscribe({
next: (res) => { next: (res) => {
this.quizzes.set(res.quizzes); this.interviews.set(res.interviews);
this.loading.set(false); this.loading.set(false);
}, },
error: () => this.loading.set(false) error: () => this.loading.set(false)
}); });
} }
onCodingZipSelected(event: any, interviewId: string): void {
const file = event.target.files[0];
if (file) {
this.uploadingCodingId.set(interviewId);
this.quizService.uploadCandidateCodingSubmission(interviewId, file).subscribe({
next: (res) => {
this.uploadingCodingId.set(null);
this.loadInterviews();
alert('Coding round submitted successfully!');
},
error: (err) => {
this.uploadingCodingId.set(null);
alert(err.error?.message || 'Failed to submit coding round');
}
});
}
}
} }
...@@ -110,22 +110,24 @@ ...@@ -110,22 +110,24 @@
/* Quick Actions */ /* Quick Actions */
.actions-grid { .actions-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(4,1fr);
gap: 16px; gap: 16px;
} }
.action-card { .action-card {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 16px; gap: 16px;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
height: 100%;
} }
.action-icon { .action-icon {
font-size: 28px; font-size: 28px;
color: var(--accent-primary); color: var(--accent-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
} }
.action-info { .action-info {
...@@ -143,12 +145,19 @@ ...@@ -143,12 +145,19 @@
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 38px;
} }
.action-arrow { .action-arrow {
color: var(--text-muted); color: var(--text-muted);
font-size: 20px; font-size: 20px;
transition: transform 0.2s; transition: transform 0.2s;
margin-top: 5px;
} }
.action-card:hover .action-arrow { .action-card:hover .action-arrow {
......
...@@ -61,6 +61,14 @@ ...@@ -61,6 +61,14 @@
<div class="section"> <div class="section">
<h2 class="section-title">Quick Actions</h2> <h2 class="section-title">Quick Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<a (click)="openInterviewPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">groups</span>
<div class="action-info">
<h3>Interviews</h3>
<p>Manage group and individual interview evaluations</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;"> <a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span> <span class="material-symbols-rounded action-icon">group</span>
<div class="action-info"> <div class="action-info">
......
...@@ -32,4 +32,8 @@ export class HRDashboardComponent implements OnInit { ...@@ -32,4 +32,8 @@ export class HRDashboardComponent implements OnInit {
openUsersPopup(): void { openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true); this.uiService.showManageUsersPopup.set(true);
} }
openInterviewPopup(): void {
this.uiService.showInterviewPopup.set(true);
}
} }
import { Component } from '@angular/core';
@Component({
selector: 'app-group-interview',
imports: [],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css',
})
export class GroupInterviewComponent {}
import { Component } from '@angular/core';
@Component({
selector: 'app-individual-interview',
imports: [],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css',
})
export class IndividualInterview {}
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1000px; margin: 0 auto; }
/* Header */
.profile-header {
display: flex; align-items: flex-start; gap: 24px; padding: 32px;
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 20px; margin-bottom: 24px; box-shadow: 0 4px 24px rgba(0,0,0,0.02);
position: relative; overflow: hidden;
}
.profile-header::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 120px;
background: linear-gradient(135deg, rgba(102,126,234,0.1), rgba(118,75,162,0.1));
z-index: 0; pointer-events: none;
}
.ph-avatar {
width: 96px; height: 96px; border-radius: 24px; background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 36px; font-weight: 700; flex-shrink: 0;
box-shadow: 0 8px 24px rgba(102,126,234,0.3); z-index: 1; border: 4px solid var(--bg-card);
}
.ph-info { flex: 1; z-index: 1; margin-top: 12px; }
.ph-info h1 { font-size: 28px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.ph-role { font-size: 13px; font-weight: 600; color: #667eea; text-transform: uppercase; letter-spacing: 1px; margin: 0 0 16px; }
.ph-meta { display: flex; flex-wrap: wrap; gap: 16px; }
.meta-item { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.ph-actions { z-index: 1; margin-top: 12px; }
/* Grid Layout */
.profile-grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
/* Cards */
.content-card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 16px; overflow: hidden;
}
.card-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.card-header h2 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.card-body { padding: 24px; }
/* Signature specific */
.signature-box {
padding: 20px; border: 2px dashed var(--border-color); border-radius: 12px;
background: var(--bg-input); text-align: center;
max-width: 400px; margin: 0 auto;
}
.signature-box img {
max-width: 100%; max-height: 120px; object-fit: contain;
}
/* Modals */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); backdrop-filter: blur(4px);
z-index: 1000; display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: var(--bg-card); border-radius: 16px; box-shadow: 0 10px 50px rgba(0,0,0,0.25);
border: 1px solid var(--border-color); width: 90%; max-width: 500px;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.modal-header h2 { font-size: 18px; font-weight: 600; margin: 0; color: var(--text-primary); }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-hover);
border-radius: 0 0 16px 16px;
}
/* Forms */
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
/* Utilities */
.btn-icon {
width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent;
color: var(--text-muted); display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s;
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
.text-muted { color: var(--text-muted); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 60px; color: var(--text-muted); gap: 16px; }
.empty-state { text-align: center; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 48px; opacity: 0.5; margin-bottom: 16px; display: block; }
.empty-state h3 { font-size: 16px; color: var(--text-primary); margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (max-width: 768px) {
.page-container { padding: 20px; }
.profile-header { flex-direction: column; align-items: center; text-align: center; padding: 24px; }
.ph-meta { justify-content: center; }
}
<div class="page-container">
<div class="content-wrapper">
@if (loading()) {
<div class="loading-center">
<span class="spinner"></span>
<p>Loading your profile...</p>
</div>
} @else if (user()) {
<div class="profile-header">
<div class="ph-avatar">
{{ user().name?.charAt(0)?.toUpperCase() }}
</div>
<div class="ph-info">
<h1>{{ user().name }}</h1>
<p class="ph-role">{{ user().role | uppercase }}</p>
<div class="ph-meta">
<span class="meta-item"><span class="material-symbols-rounded">mail</span> {{ user().email }}</span>
@if (user().phoneNumber) {
<span class="meta-item"><span class="material-symbols-rounded">call</span> {{ user().phoneNumber }}</span>
}
</div>
</div>
<div class="ph-actions">
<button class="btn btn-outline" (click)="openEditProfile()">
<span class="material-symbols-rounded">edit</span> Edit Profile
</button>
</div>
</div>
<div class="profile-grid">
<div class="pg-main">
<!-- Signature Section -->
<div class="content-card">
<div class="card-header">
<h2>Digital Signature</h2>
</div>
<div class="card-body">
@if (user().signature) {
<div class="signature-box">
<img [src]="getSignatureUrl()" alt="Your Signature">
</div>
<div class="action-row" style="margin-top: 16px;">
<label class="btn btn-primary" style="cursor: pointer;">
@if (isUploadingSignature()) { <span class="spinner spinner-sm"></span> Uploading... }
@else { <span class="material-symbols-rounded">upload</span> Replace Signature }
<input type="file" accept="image/*" style="display: none;" (change)="uploadSignature($event)">
</label>
</div>
} @else {
<div class="empty-state" style="padding: 40px 20px;">
<span class="material-symbols-rounded">draw</span>
<h3>No Signature Uploaded</h3>
<p>Upload your digital signature to attach it to evaluation forms automatically.</p>
<label class="btn btn-primary" style="margin-top: 16px; cursor: pointer; display: inline-flex;">
@if (isUploadingSignature()) { <span class="spinner spinner-sm"></span> Uploading... }
@else { <span class="material-symbols-rounded">upload</span> Upload Signature }
<input type="file" accept="image/*" style="display: none;" (change)="uploadSignature($event)">
</label>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>
<!-- Edit Profile Modal -->
@if (isEditingProfile()) {
<div class="modal-overlay" (click)="closeEditProfile()">
<div class="modal-container" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Edit Profile</h2>
<button class="btn-icon" (click)="closeEditProfile()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Full Name *</label>
<input class="form-input" [(ngModel)]="editName" placeholder="Enter your full name">
</div>
<div class="form-group">
<label class="form-label">Email *</label>
<input class="form-input" [(ngModel)]="editEmail" placeholder="Enter your email" type="email">
</div>
<div class="form-group">
<label class="form-label">Phone Number</label>
<input class="form-input" [(ngModel)]="editPhone" placeholder="Enter phone number">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeEditProfile()">Cancel</button>
<button class="btn btn-primary" (click)="saveProfile()" [disabled]="isSavingProfile()">
@if (isSavingProfile()) { <span class="spinner spinner-sm"></span> Saving... }
@else { Save Changes }
</button>
</div>
</div>
</div>
}
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-staff-profile',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './profile.html',
styleUrl: './profile.css'
})
export class StaffProfileComponent implements OnInit {
user = signal<any>(null);
loading = signal(true);
// Edit Profile State
isEditingProfile = signal(false);
isSavingProfile = signal(false);
editName = signal('');
editEmail = signal('');
editPhone = signal('');
isUploadingSignature = signal(false);
constructor(public authService: AuthService) {}
ngOnInit(): void {
this.authService.getMe().subscribe({
next: (res) => {
this.user.set(res.user);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
openEditProfile() {
const currentUser = this.user();
if (currentUser) {
this.editName.set(currentUser.name || '');
this.editEmail.set(currentUser.email || '');
this.editPhone.set(currentUser.phoneNumber || '');
this.isEditingProfile.set(true);
}
}
closeEditProfile() {
this.isEditingProfile.set(false);
}
saveProfile() {
if (!this.editName().trim() || !this.editEmail().trim()) {
alert("Name and Email are required");
return;
}
this.isSavingProfile.set(true);
const formData = new FormData();
formData.append('name', this.editName());
formData.append('email', this.editEmail());
formData.append('phoneNumber', this.editPhone());
this.authService.updateProfile(formData).subscribe({
next: (res) => {
this.user.set(res.user);
// Also update authService currentUser partially
const current = this.authService.currentUser();
if (current) {
this.authService.currentUser.set({...current, name: res.user.name, email: res.user.email});
}
this.isSavingProfile.set(false);
this.closeEditProfile();
},
error: (err) => {
console.error(err);
alert('Failed to update profile');
this.isSavingProfile.set(false);
}
});
}
uploadSignature(event: any): void {
const file = event.target.files[0];
if (!file) return;
this.isUploadingSignature.set(true);
const formData = new FormData();
formData.append('signature', file);
this.authService.uploadSignature(formData).subscribe({
next: (res) => {
this.isUploadingSignature.set(false);
if (res.signature) {
// Update user signal
this.user.update(u => ({ ...u, signature: res.signature }));
}
},
error: (err) => {
this.isUploadingSignature.set(false);
alert(err.error?.message || 'Error uploading signature');
}
});
}
deleteSignature(): void {
if (!confirm('Are you sure you want to delete your signature?')) return;
// We could make a DELETE endpoint, but for now we just upload an empty file or assume there is an endpoint.
// Actually, backend auth.js has no delete signature endpoint. Let's just alert that it's not supported yet
// or we can just not provide delete for now, or add an endpoint.
alert('Deleting signature is not implemented yet. Please upload a new one to replace it.');
}
getSignatureUrl(): string {
const signature = this.user()?.signature;
if (!signature) return '';
return `http://localhost:5000${signature}`;
}
}
...@@ -7,7 +7,7 @@ export interface User { ...@@ -7,7 +7,7 @@ export interface User {
id: string; id: string;
name: string; name: string;
email: string; email: string;
role: 'admin' | 'hr' | 'candidate'; role: 'admin' | 'hr' | 'candidate' | 'pm' | 'interviewer';
group?: string; group?: string;
} }
...@@ -52,6 +52,18 @@ export class AuthService { ...@@ -52,6 +52,18 @@ export class AuthService {
.pipe(tap(res => this.handleAuth(res))); .pipe(tap(res => this.handleAuth(res)));
} }
getMe(): Observable<any> {
return this.http.get(`${this.apiUrl}/me`);
}
uploadSignature(formData: FormData): Observable<any> {
return this.http.post(`${this.apiUrl}/signature`, formData);
}
updateProfile(formData: FormData): Observable<any> {
return this.http.put(`${this.apiUrl}/profile`, formData);
}
logout(): void { logout(): void {
const token = sessionStorage.getItem('token'); const token = sessionStorage.getItem('token');
if (token) { if (token) {
...@@ -95,6 +107,8 @@ export class AuthService { ...@@ -95,6 +107,8 @@ export class AuthService {
case 'admin': return '/admin/dashboard'; case 'admin': return '/admin/dashboard';
case 'hr': return '/hr/dashboard'; case 'hr': return '/hr/dashboard';
case 'candidate': return '/candidate/dashboard'; case 'candidate': return '/candidate/dashboard';
case 'pm': return '/pm/dashboard';
case 'interviewer': return '/interviewer/dashboard';
default: return '/login'; default: return '/login';
} }
} }
......
...@@ -27,8 +27,8 @@ ...@@ -27,8 +27,8 @@
return this.http.get(`${this.getBaseUrl()}/users/logged-in`); return this.http.get(`${this.getBaseUrl()}/users/logged-in`);
} }
createHRUser(data: { name: string; email: string; password: string }): Observable<any> { createStaffUser(data: { name: string; email: string; password: string; role: string }): Observable<any> {
return this.http.post(`${this.adminUrl}/users/create-hr`, data); // Only admin can create HR return this.http.post(`${this.adminUrl}/users/create-staff`, data); // Only admin can create staff
} }
deleteUser(userId: string): Observable<any> { deleteUser(userId: string): Observable<any> {
...@@ -40,13 +40,13 @@ ...@@ -40,13 +40,13 @@
} }
getUserHistory(userId: string): Observable<any> { getUserHistory(userId: string): Observable<any> {
// 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`); return this.http.get(`${this.getBaseUrl()}/users/${userId}/history`);
} }
getUserInterviews(userId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/users/${userId}/interviews`);
}
updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> { updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> {
return this.http.put(`${this.getBaseUrl()}/users/${userId}/level`, { level }); return this.http.put(`${this.getBaseUrl()}/users/${userId}/level`, { level });
} }
...@@ -167,6 +167,10 @@ ...@@ -167,6 +167,10 @@
return this.http.get(`${this.candidateUrl}/quizzes`); return this.http.get(`${this.candidateUrl}/quizzes`);
} }
getCandidateInterviews(): Observable<any> {
return this.http.get(`${this.candidateUrl}/interviews`);
}
getQuizForTaking(quizId: string): Observable<any> { getQuizForTaking(quizId: string): Observable<any> {
return this.http.get(`${this.candidateUrl}/quiz/${quizId}`); return this.http.get(`${this.candidateUrl}/quiz/${quizId}`);
} }
...@@ -191,6 +195,12 @@ ...@@ -191,6 +195,12 @@
return this.http.get(`${this.candidateUrl}/results/${submissionId}`); return this.http.get(`${this.candidateUrl}/results/${submissionId}`);
} }
uploadCandidateCodingSubmission(interviewId: string, file: File): Observable<any> {
const formData = new FormData();
formData.append('codingZip', file);
return this.http.post(`${this.candidateUrl}/interview/${interviewId}/coding`, formData);
}
// ========== GENERIC GROUP MANAGEMENT ========== // ========== GENERIC GROUP MANAGEMENT ==========
private getBaseUrl(): string { private getBaseUrl(): string {
...@@ -223,4 +233,62 @@ ...@@ -223,4 +233,62 @@
assignUserGroup(userId: string, groupName: string): Observable<any> { assignUserGroup(userId: string, groupName: string): Observable<any> {
return this.http.put(`${this.getBaseUrl()}/users/${userId}/group`, { group: groupName }); return this.http.put(`${this.getBaseUrl()}/users/${userId}/group`, { group: groupName });
} }
// ========== INTERVIEW ENDPOINTS ==========
private interviewUrl = 'http://localhost:5000/api/interview';
getInterviews(params?: any): Observable<any> {
let url = this.interviewUrl;
if (params) {
const query = Object.entries(params).filter(([,v]) => v).map(([k,v]) => `${k}=${v}`).join('&');
if (query) url += `?${query}`;
}
return this.http.get(url);
}
getInterviewStats(): Observable<any> {
return this.http.get(`${this.interviewUrl}/stats`);
}
getInterviewById(id: string): Observable<any> {
return this.http.get(`${this.interviewUrl}/${id}`);
}
createInterview(data: any): Observable<any> {
return this.http.post(this.interviewUrl, data);
}
updateInterview(id: string, data: any): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}`, data);
}
submitEvaluation(id: string, data: any): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}/evaluate`, data);
}
setInterviewDecision(id: string, decision: string): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}/decision`, { decision });
}
uploadCodingSubmission(id: string, file: File): Observable<any> {
const formData = new FormData();
formData.append('codingZip', file);
return this.http.post(`${this.interviewUrl}/${id}/coding-submission`, formData);
}
validateCodingRound(id: string): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}/validate-coding`, {});
}
deleteInterview(id: string): Observable<any> {
return this.http.delete(`${this.interviewUrl}/${id}`);
}
getInterviewers(): Observable<any> {
return this.http.get(`${this.interviewUrl}/interviewers`);
}
getInterviewCandidates(): Observable<any> {
return this.http.get(`${this.interviewUrl}/candidates`);
}
} }
...@@ -5,4 +5,5 @@ import { Injectable, signal } from '@angular/core'; ...@@ -5,4 +5,5 @@ import { Injectable, signal } from '@angular/core';
}) })
export class UiService { export class UiService {
showManageUsersPopup = signal<boolean>(false); showManageUsersPopup = signal<boolean>(false);
showInterviewPopup = signal<boolean>(false);
} }
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