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) => {
'application/vnd.ms-excel', // .xls
'application/pdf', // .pdf
'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 (
......@@ -33,11 +40,16 @@ const fileFilter = (req, file, cb) => {
file.originalname.endsWith('.xls') ||
file.originalname.endsWith('.pdf') ||
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);
} 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({
storage: storage,
fileFilter: fileFilter,
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({
type: String,
default: ''
},
signature: {
type: String,
default: ''
},
password: {
type: String,
required: [true, 'Password is required'],
......@@ -30,7 +34,7 @@ const userSchema = new mongoose.Schema({
},
role: {
type: String,
enum: ['admin', 'hr', 'candidate'],
enum: ['admin', 'hr', 'candidate', 'pm', 'interviewer'],
default: 'candidate'
},
group: {
......
......@@ -21,7 +21,7 @@
router.get('/users', async (req, res) => {
try {
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)
.select('-password')
.sort({ createdAt: -1 });
......@@ -47,15 +47,20 @@
}
});
// @route POST /api/admin/users/create-hr
// @desc Create an HR user
// @route POST /api/admin/users/create-staff
// @desc Create a staff user (HR, PM, Interviewer)
// @access Admin
router.post('/users/create-hr', async (req, res) => {
router.post('/users/create-staff', async (req, res) => {
try {
const { name, email, password } = req.body;
const { name, email, password, role } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ message: 'Please provide name, email, and password' });
if (!name || !email || !password || !role) {
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 });
......@@ -63,10 +68,10 @@
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({
message: 'HR user created successfully',
message: 'Staff user created successfully',
user: {
id: user._id,
name: user.name,
......@@ -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
// @desc Get detailed submission - answers, correct answers, scores
// @access Admin
......
......@@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken');
const User = require('../models/User');
const Group = require('../models/Group');
const { protect } = require('../middleware/auth');
const upload = require('../middleware/upload');
const fs = require('fs');
const path = require('path');
const router = express.Router();
// Generate JWT Token
......@@ -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
// @desc Get all groups for registration
// @access Public
......@@ -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;
......@@ -12,6 +12,24 @@ const router = express.Router();
// All candidate routes require authentication + candidate role
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
// @desc Get assigned and available quizzes for the candidate
// @access Candidate
......@@ -86,9 +104,18 @@ router.get('/quiz/:quizId', async (req, res) => {
// Check assignment - is this candidate allowed to take this quiz?
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 ||
quiz.assignees.some(a => a.toString() === req.user._id.toString()) ||
quiz.assignedGroups.includes(user.group);
quiz.assignedGroups.includes(user.group) ||
!!interview;
if (!isAssigned) {
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) => {
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({
message: 'Quiz submitted successfully',
result: {
......@@ -257,6 +298,44 @@ function checkAnswersMatch(arr1, arr2) {
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
// @desc Get candidate profile including topics of interest
// @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 @@
app.use('/api/admin', require('./routes/admin'));
app.use('/api/hr', require('./routes/hr'));
app.use('/api/candidate', require('./routes/candidate'));
app.use('/api/interview', require('./routes/interview'));
// Keep backward compatibility for old student endpoints
app.use('/api/student', require('./routes/candidate'));
......
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';
export const routes: Routes = [
......@@ -42,13 +42,21 @@ export const routes: Routes = [
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)
},
{
path: 'submissions/:submissionId',
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',
loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent)
......@@ -65,6 +73,10 @@ export const routes: Routes = [
path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/admin/edit-quiz/edit-quiz').then(m => m.EditQuizComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
]
},
......@@ -83,7 +95,7 @@ export const routes: Routes = [
path: 'quizzes',
loadComponent: () => import('./pages/hr/quizzes/quizzes').then(m => m.HRQuizzesComponent)
},
{
{
path: 'users/:userId/history',
loadComponent: () => import('./pages/hr/user-history/user-history').then(m => m.HRUserHistoryComponent)
},
......@@ -111,6 +123,58 @@ export const routes: Routes = [
path: 'quiz/:quizId/edit',
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 @@
color: #fff;
}
.role-pm {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #fff;
}
.role-interviewer {
background: linear-gradient(135deg, #06b6d4, #0891b2);
color: #fff;
}
/* Navigation items */
.sidebar-nav {
display: flex;
......@@ -354,6 +364,7 @@
}
/* Glassy Modal Styles */
.glassy-overlay {
position: fixed;
top: 0;
......@@ -375,11 +386,13 @@
border-radius: 20px;
width: 90%;
max-width: 550px;
max-height: 80vh;
padding: 32px;
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);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
......@@ -415,6 +428,9 @@
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding-right: 8px; /* Space for scrollbar */
padding-bottom: 8px;
}
.glassy-option {
......
......@@ -13,7 +13,8 @@
<div class="mobile-actions">
<button class="icon-btn" (click)="toggleThemeMenu()">
<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>
</button>
</div>
......@@ -37,22 +38,20 @@
<!-- Navigation -->
<nav class="sidebar-nav">
@for (item of navItems(); track item.route) {
@if (item.route === '/admin/users' || item.route === '/hr/users') {
<a class="nav-item" (click)="handleNavClick(item, $event)" href="javascript:void(0)"
[class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/users') || router.url.includes('manage-groups')">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
} @else {
<a [routerLink]="item.route"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}"
class="nav-item"
(click)="mobileSidebarOpen = false">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
}
@if (item.route === '/admin/users' || item.route === '/hr/users') {
<a class="nav-item" (click)="handleNavClick(item, $event)" href="javascript:void(0)"
[class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/users') || router.url.includes('manage-groups')">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
} @else {
<a [routerLink]="item.route" routerLinkActive="active"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}" class="nav-item"
(click)="mobileSidebarOpen = false">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
}
}
</nav>
......@@ -61,13 +60,10 @@
<div class="section-label">Theme</div>
<div class="theme-switcher">
@for (t of themes; track t.id) {
<button
class="theme-btn"
[class.active]="themeService.currentTheme() === t.id"
(click)="setTheme(t.id)"
[title]="t.label">
<span class="material-symbols-rounded">{{ t.icon }}</span>
</button>
<button class="theme-btn" [class.active]="themeService.currentTheme() === t.id" (click)="setTheme(t.id)"
[title]="t.label">
<span class="material-symbols-rounded">{{ t.icon }}</span>
</button>
}
</div>
</div>
......@@ -81,6 +77,7 @@
<span class="user-email">{{ authService.currentUser()?.email }}</span>
</div>
</div>
<button class="logout-btn" (click)="logout()">
<span class="material-symbols-rounded">logout</span>
<span>Sign Out</span>
......@@ -97,51 +94,128 @@
<!-- Manage Users Glassy Popup -->
@if (uiService.showManageUsersPopup()) {
<div class="glassy-overlay" (click)="closeManageUsersPopup()">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Manage Users & Groups</h2>
<button class="close-btn" (click)="closeManageUsersPopup()">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-options">
<a [routerLink]="getUsersRoute()" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">people</span>
</div>
<div class="option-text">
<h3>Student Users</h3>
<p>Review and manage candidate profiles and test histories</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
<a [routerLink]="getManageGroupsRoute()" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">group_add</span>
</div>
<div class="option-text">
<h3>Manage Groups</h3>
<p>Create and structure student categorization effectively</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
@if (authService.getUserRole() === 'admin') {
<a routerLink="/admin/hr-users" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">assignment_ind</span>
</div>
<div class="option-text">
<h3>HR Users</h3>
<p>Manage HR staff platform access</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
}
</div>
<div class="glassy-overlay" (click)="closeManageUsersPopup()">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Manage Users & Groups</h2>
<button class="close-btn" (click)="closeManageUsersPopup()">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-options">
<a [routerLink]="getUsersRoute()" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">people</span>
</div>
<div class="option-text">
<h3>Student Users</h3>
<p>Review and manage candidate profiles and test histories</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
<a [routerLink]="getManageGroupsRoute()" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">group_add</span>
</div>
<div class="option-text">
<h3>Manage Groups</h3>
<p>Create and structure student categorization effectively</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
@if (authService.getUserRole() === 'admin') {
<a routerLink="/admin/staff/hr" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">assignment_ind</span>
</div>
<div class="option-text">
<h3>HR Users</h3>
<p>Manage HR staff platform access</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</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>
}
@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>
<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 {
{ icon: 'group', label: 'Users', route: '/admin/users' },
{ icon: 'quiz', label: 'Quizzes', route: '/admin/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/admin/create-quiz' },
{ icon: 'person', label: 'Profile', route: '/admin/profile' },
];
case 'hr':
return [
......@@ -49,6 +50,20 @@ export class LayoutComponent {
{ icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' },
{ 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':
return [
......@@ -66,6 +81,8 @@ export class LayoutComponent {
case 'admin': return { label: 'Admin', class: 'role-admin' };
case 'hr': return { label: 'HR', class: 'role-hr' };
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: '' };
}
});
......@@ -107,7 +124,7 @@ export class LayoutComponent {
getUsersRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/users' : '/admin/users';
}
getManageGroupsRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/manage-groups' : '/admin/manage-groups';
}
......
......@@ -99,3 +99,39 @@ export const guestGuard: CanActivateFn = () => {
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 @@
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.action-card {
display: flex;
align-items: center;
align-items: flex-start;
gap: 16px;
text-decoration: none;
color: inherit;
height: 100%;
}
.action-icon {
font-size: 28px;
color: var(--accent-primary);
flex-shrink: 0;
margin-top: 2px;
}
.action-info {
......@@ -143,12 +145,19 @@
font-size: 13px;
color: var(--text-muted);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 38px;
}
.action-arrow {
color: var(--text-muted);
font-size: 20px;
transition: transform 0.2s;
margin-top: 5px;
}
.action-card:hover .action-arrow {
......
......@@ -8,135 +8,143 @@
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else if (stats()) {
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap purple">
<span class="material-symbols-rounded">badge</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalHR }}</span>
<span class="stat-label">HR Users</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap purple">
<span class="material-symbols-rounded">badge</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalHR }}</span>
<span class="stat-label">HR Users</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<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;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/admin/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/admin/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/admin/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span>
<div class="action-info">
<h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
<a routerLink="/admin/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span>
<div class="action-info">
<h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
</div>
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
'badge-success': sub.percentage >= 70,
'badge-warning': sub.percentage >= 40 && sub.percentage < 70,
'badge-danger': sub.percentage < 40
}">{{ sub.percentage }}%</span>
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
</div>
\ No newline at end of file
......@@ -32,4 +32,9 @@ export class AdminDashboardComponent implements OnInit {
openUsersPopup(): void {
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-header">
<h1>HR Users</h1>
<p class="page-subtitle">Manage HR Staff platform access and identity</p>
<div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<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)'">
<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>
@if (error()) {
......@@ -12,7 +17,7 @@
}
<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">
<div class="form-group row-align">
<div class="input-container">
......@@ -32,14 +37,14 @@
@if (creating()) {
<span class="spinner"></span>
} @else {
<span class="material-symbols-rounded">person_add</span> Add HR
<span class="material-symbols-rounded">person_add</span> Add {{ getRoleDisplay() }}
}
</button>
</div>
</form>
</div>
<h2 class="section-title">Existing HR Users</h2>
<h2 class="section-title">Existing {{ getRoleDisplay() }}s</h2>
@if (loading()) {
<div class="loading-state">
<div class="loader"></div>
......@@ -47,8 +52,8 @@
} @else if (hrUsers().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">manage_accounts</span>
<h3>No HR users available</h3>
<p>Add your first HR user above</p>
<h3>No {{ getRoleDisplay() }}s available</h3>
<p>Add your first {{ getRoleDisplay() }} above</p>
</div>
} @else {
<div class="groups-grid">
......
import { Component, OnInit, signal } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
......@@ -18,10 +19,11 @@ export class AdminHrUsersComponent implements OnInit {
error = signal<string>('');
success = signal<string>('');
// Create New HR
// Create New Staff
newName = signal('');
newEmail = signal('');
newPassword = signal('');
currentRole = signal('hr');
creating = signal(false);
// Edit HR
......@@ -31,17 +33,29 @@ export class AdminHrUsersComponent implements OnInit {
editPassword = signal('');
saving = signal(false);
constructor(private quizService: QuizService) {}
constructor(private quizService: QuizService, private route: ActivatedRoute, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void {
this.loadHRUsers();
this.route.paramMap.subscribe(params => {
const role = params.get('role');
if (role) {
this.currentRole.set(role);
}
this.loadHRUsers();
});
}
loadHRUsers(): void {
this.loading.set(true);
this.quizService.getUsers('hr').subscribe({
this.quizService.getUsers().subscribe({
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);
},
error: () => this.loading.set(false)
......@@ -50,7 +64,7 @@ export class AdminHrUsersComponent implements OnInit {
createHRUser(): void {
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;
}
......@@ -58,13 +72,14 @@ export class AdminHrUsersComponent implements OnInit {
this.error.set('');
this.success.set('');
this.quizService.createHRUser({
this.quizService.createStaffUser({
name: this.newName().trim(),
email: this.newEmail().trim(),
password: this.newPassword().trim()
password: this.newPassword().trim(),
role: this.currentRole()
}).subscribe({
next: () => {
this.success.set('HR User created successfully!');
this.success.set('Staff User created successfully!');
this.creating.set(false);
this.newName.set('');
this.newEmail.set('');
......@@ -72,7 +87,7 @@ export class AdminHrUsersComponent implements OnInit {
this.loadHRUsers();
},
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);
}
});
......@@ -101,32 +116,39 @@ export class AdminHrUsersComponent implements OnInit {
this.quizService.editUser(userId, data).subscribe({
next: () => {
this.success.set('HR User updated successfully!');
this.success.set('Staff User updated successfully!');
this.saving.set(false);
this.cancelEdit();
this.loadHRUsers();
},
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);
}
});
}
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.success.set('');
this.quizService.deleteUser(userId).subscribe({
next: () => {
this.success.set('HR User deleted successfully!');
this.success.set('Staff User deleted successfully!');
this.loadHRUsers();
},
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="main-workspace">
<div class="page-header">
<h1>Manage Groups</h1>
<p class="page-subtitle">Create organizational groups and effortlessly drag-and-drop candidates into them.</p>
<div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<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)'">
<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>
@if (error()) {
......
import { Component, signal, OnInit, computed } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
......@@ -31,7 +31,11 @@ export class ManageGroupsComponent implements OnInit {
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 {
this.loadGroups();
......
......@@ -3,10 +3,212 @@
max-width: 1400px;
}
/* ========== BREADCRUMB ========== */
.breadcrumb {
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 {
color: #667eea;
text-decoration: none;
......
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<!-- 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>
@if (loading()) {
@if (loadingInterviews()) {
<div class="loading-state">
<div class="loader"></div>
<p>Loading history...</p>
<p>Loading candidate data...</p>
</div>
} @else {
<!-- ========== CANDIDATE HEADER CARD ========== -->
@if (user()) {
<div class="student-header">
<div class="student-profile">
......@@ -26,7 +38,6 @@
</span>
}
</div>
@if (user().resume) {
<div class="student-resume-info">
<a [href]="getResumeUrl(user().resume)" target="_blank" class="resume-badge">
......@@ -54,55 +65,205 @@
</div>
}
<h2 class="section-title">Test History</h2>
@if (submissions().length === 0) {
<div class="empty-state">
<span class="empty-icon">📋</span>
<h3>No tests taken yet</h3>
<p>This student hasn't taken any quizzes.</p>
<!-- ========== 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>
} @else {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>Quiz Name</th>
<th>Topic</th>
<th>Candidate's comfort Level</th>
<th>Score</th>
<th>Percentage</th>
<th>Time Taken</th>
<th>Date</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sub of submissions(); track sub._id) {
@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>
<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>
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
[class.avg]="sub.percentage >= 40 && sub.percentage < 70"
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
</td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'medium' }}</td>
<td>
<a [routerLink]="['/admin/submissions', sub._id]" class="view-btn">View Details</a>
</td>
<th>#</th>
<th>Position</th>
<th>Interviewer</th>
<th>Quizzes</th>
<th>Status</th>
<th>Date</th>
<th>Action</th>
</tr>
}
</tbody>
</table>
</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 (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">
<span class="material-symbols-rounded empty-icon">assignment</span>
<h3>No quizzes assigned</h3>
<p>No assessment rounds were linked to this interview.</p>
</div>
} @else {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>Quiz ID</th>
<th>Quiz Name</th>
<th>Topic</th>
<th>Level</th>
<th>Comfort Level</th>
<th>Score</th>
<th>Percentage</th>
<th>Time Taken</th>
<th>Date</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (iq of selectedInterview().quizzes; track iq._id; let i = $index) {
<tr>
<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-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
[class.avg]="sub.percentage >= 40 && sub.percentage < 70"
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
} @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>
@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>
</tr>
}
</tbody>
</table>
</div>
}
}
}
......
......@@ -14,11 +14,20 @@ import { QuizService } from '../../../services/quiz.service';
export class UserHistoryComponent implements OnInit {
userId = '';
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[]>([]);
loading = signal<boolean>(true);
loadingInterviews = signal<boolean>(true);
loadingSubmissions = signal<boolean>(false);
currentLevel = signal<string>('beginner');
toast = signal<{ message: string; type: 'success' | 'error' } | null>(null);
// Expose String for template
readonly String = String;
levels = [
{ value: 'beginner', label: 'Fresher' },
{ value: 'intermediate', label: 'Intern' },
......@@ -26,6 +35,13 @@ export class UserHistoryComponent implements OnInit {
{ value: 'expert', label: 'Expert' }
];
/** Difficulty → level code mapping */
private levelCodeMap: Record<string, string> = {
easy: 'BEG',
medium: 'INT',
hard: 'ADV'
};
constructor(
private route: ActivatedRoute,
public authService: AuthService,
......@@ -34,28 +50,63 @@ export class UserHistoryComponent implements OnInit {
ngOnInit(): void {
this.userId = this.route.snapshot.params['userId'];
this.loadHistory();
this.loadInterviews();
}
loadHistory(): void {
this.quizService.getUserHistory(this.userId).subscribe({
loadInterviews(): void {
this.loadingInterviews.set(true);
this.quizService.getUserInterviews(this.userId).subscribe({
next: (res) => {
this.user.set(res.user);
this.currentLevel.set(res.user.level || 'beginner');
this.submissions.set(res.submissions);
this.loading.set(false);
this.interviews.set(res.interviews);
this.loadingInterviews.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.loading.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 {
if (newLevel === this.currentLevel()) return;
const previousLevel = this.currentLevel();
const userRole = this.authService.currentUser()?.role || 'admin';
this.quizService.updateUserLevel(this.userId, newLevel, userRole).subscribe({
this.quizService.updateUserLevel(this.userId, newLevel).subscribe({
next: (res) => {
this.currentLevel.set(newLevel);
this.showToast(
......@@ -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 {
this.toast.set({ message, type });
setTimeout(() => this.toast.set(null), 3500);
......@@ -87,15 +156,10 @@ export class UserHistoryComponent implements OnInit {
getComfortLevel(topic: string): string {
const u = this.user();
if (!u || !u.topicsOfInterest || !topic) return 'N/A';
const interest = u.topicsOfInterest.find((t: any) => t.topic.toLowerCase() === topic.toLowerCase());
return interest ? `${interest.comfortLevel}%` : 'N/A';
}
logout(): void {
this.authService.logout();
}
getResumeUrl(resumePath: string): string {
if (!resumePath) return '';
return `http://localhost:5000${resumePath}`;
......
<div class="page-container animate-fade-in">
<div class="page-header">
<h1>Student Users</h1>
<p>View and manage registered students</p>
<div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<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)'">
<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 class="filter-tabs">
<button class="tab" [class.active]="showAll()" (click)="toggleFilter(true)">All Students</button>
......
import { Component, OnInit, signal } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
......@@ -16,7 +17,11 @@ export class AdminUsersComponent implements OnInit {
loading = 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 {
this.loadUsers();
......
......@@ -3,81 +3,179 @@
<div class="page-header">
<div>
<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>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading quizzes...</p>
<p class="loading-text">Loading interviews...</p>
</div>
} @else if (quizzes().length === 0) {
} @else if (interviews().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">assignment</span>
<h3>No quizzes assigned</h3>
<p>Check back later for new assessments</p>
<span class="material-symbols-rounded">event_busy</span>
<h3>No interviews assigned</h3>
<p>Check back later for new opportunities</p>
</div>
} @else {
<div class="quiz-grid stagger-children">
@for (quiz of quizzes(); track quiz.id) {
<div class="quiz-card card" [class.taken]="quiz.taken">
<div class="quiz-card-header">
<div class="quiz-icon-wrap" [class.completed]="quiz.taken">
<span class="material-symbols-rounded">{{ quiz.taken ? 'task_alt' : 'description' }}</span>
<div class="interview-list stagger-children">
@for (interview of interviews(); track interview._id) {
<div class="interview-card card" style="padding: 32px; margin-bottom: 24px;">
<!-- Header Area -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<h2 style="margin: 0; font-size: 28px; font-weight: 700; color: var(--text-primary);">{{ interview.position || 'Software Engineering Interview' }}</h2>
</div>
<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 class="quiz-meta">
@if (quiz.category) {
<span class="badge badge-primary" title="{{ quiz.category }}">{{ quiz.category | uppercase }}</span>
}
@if (quiz.difficulty) {
<span class="badge difficulty-badge" [ngClass]="{
'badge-success': quiz.difficulty.toLowerCase() === 'easy',
'badge-warning': quiz.difficulty.toLowerCase() === 'medium',
'badge-danger': quiz.difficulty.toLowerCase() === 'hard'
}">{{ quiz.difficulty | uppercase }}</span>
}
<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>
<h3 class="quiz-title">{{ quiz.title }}</h3>
<div class="quiz-details">
<span class="quiz-detail">
<span class="material-symbols-rounded">timer</span>
{{ quiz.timer }} min
</span>
<span class="quiz-detail">
<span class="material-symbols-rounded">help</span>
{{ quiz.totalQuestions }} questions
</span>
</div>
@if (quiz.taken) {
<div class="quiz-result">
<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>
<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>
} @else {
<div class="quiz-card-footer">
<a [routerLink]="['/candidate/quiz', quiz.id]" class="btn btn-primary btn-sm">
Start Quiz
<span class="material-symbols-rounded" style="font-size: 16px">arrow_forward</span>
</a>
<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 (q.quizId?.difficulty) {
<span class="badge" [ngClass]="{
'badge-success': q.quizId?.difficulty?.toLowerCase() === 'easy',
'badge-warning': q.quizId?.difficulty?.toLowerCase() === 'medium',
'badge-danger': q.quizId?.difficulty?.toLowerCase() === 'hard'
}">{{ q.quizId?.difficulty | uppercase }}</span>
}
</div>
</div>
<ul style="margin: 0 0 24px 0; padding-left: 20px; color: var(--text-muted); line-height: 1.6;">
<li>In this round, you'll take a structured online assessment consisting of {{ q.quizId?.totalQuestions }} multiple-choice questions.</li>
<li>This evaluates your technical knowledge, analytical thinking, and job-relevant skills.</li>
<li>You will have {{ q.quizId?.timer }} minutes to complete this assessment.</li>
</ul>
<div style="display: flex; justify-content: flex-end; align-items: center;">
@if (q.completed || q.score !== null) {
<div style="display: flex; align-items: center; gap: 16px;">
<!-- 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;">
<span class="material-symbols-rounded" style="font-size: 18px;">check_circle</span> Completed
</div>
</div>
} @else if (i === 0 || interview.quizzes[i-1].completed) {
<a [routerLink]="['/candidate/quiz', q.quizId?._id || q.quizId]" class="btn btn-primary" style="padding: 10px 24px; border-radius: 8px; font-weight: 600;">
Start Assessment
</a>
}
</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>
} @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>
......
......@@ -12,18 +12,41 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './dashboard.css'
})
export class CandidateDashboardComponent implements OnInit {
quizzes = signal<any[]>([]);
interviews = signal<any[]>([]);
loading = signal(true);
uploadingCodingId = signal<string | null>(null);
constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getAvailableQuizzes().subscribe({
this.loadInterviews();
}
loadInterviews(): void {
this.quizService.getCandidateInterviews().subscribe({
next: (res) => {
this.quizzes.set(res.quizzes);
this.interviews.set(res.interviews);
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 @@
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.action-card {
display: flex;
align-items: center;
align-items: flex-start;
gap: 16px;
text-decoration: none;
color: inherit;
height: 100%;
}
.action-icon {
font-size: 28px;
color: var(--accent-primary);
flex-shrink: 0;
margin-top: 2px;
}
.action-info {
......@@ -143,12 +145,19 @@
font-size: 13px;
color: var(--text-muted);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 38px;
}
.action-arrow {
color: var(--text-muted);
font-size: 20px;
transition: transform 0.2s;
margin-top: 5px;
}
.action-card:hover .action-arrow {
......
......@@ -8,126 +8,134 @@
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else if (stats()) {
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<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;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span>
<div class="action-info">
<h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
<a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span>
<div class="action-info">
<h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
</div>
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
'badge-success': sub.percentage >= 70,
'badge-warning': sub.percentage >= 40 && sub.percentage < 70,
'badge-danger': sub.percentage < 40
}">{{ sub.percentage }}%</span>
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
</div>
\ No newline at end of file
......@@ -32,4 +32,8 @@ export class HRDashboardComponent implements OnInit {
openUsersPopup(): void {
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 {
id: string;
name: string;
email: string;
role: 'admin' | 'hr' | 'candidate';
role: 'admin' | 'hr' | 'candidate' | 'pm' | 'interviewer';
group?: string;
}
......@@ -52,6 +52,18 @@ export class AuthService {
.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 {
const token = sessionStorage.getItem('token');
if (token) {
......@@ -95,6 +107,8 @@ export class AuthService {
case 'admin': return '/admin/dashboard';
case 'hr': return '/hr/dashboard';
case 'candidate': return '/candidate/dashboard';
case 'pm': return '/pm/dashboard';
case 'interviewer': return '/interviewer/dashboard';
default: return '/login';
}
}
......
......@@ -27,8 +27,8 @@
return this.http.get(`${this.getBaseUrl()}/users/logged-in`);
}
createHRUser(data: { name: string; email: string; password: string }): Observable<any> {
return this.http.post(`${this.adminUrl}/users/create-hr`, data); // Only admin can create HR
createStaffUser(data: { name: string; email: string; password: string; role: string }): Observable<any> {
return this.http.post(`${this.adminUrl}/users/create-staff`, data); // Only admin can create staff
}
deleteUser(userId: string): Observable<any> {
......@@ -40,13 +40,13 @@
}
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`);
}
getUserInterviews(userId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/users/${userId}/interviews`);
}
updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> {
return this.http.put(`${this.getBaseUrl()}/users/${userId}/level`, { level });
}
......@@ -167,6 +167,10 @@
return this.http.get(`${this.candidateUrl}/quizzes`);
}
getCandidateInterviews(): Observable<any> {
return this.http.get(`${this.candidateUrl}/interviews`);
}
getQuizForTaking(quizId: string): Observable<any> {
return this.http.get(`${this.candidateUrl}/quiz/${quizId}`);
}
......@@ -191,6 +195,12 @@
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 ==========
private getBaseUrl(): string {
......@@ -223,4 +233,62 @@
assignUserGroup(userId: string, groupName: string): Observable<any> {
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';
})
export class UiService {
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