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

refactor : changed the user history from quiz to listing interviesws

parent c23c917d
...@@ -24,7 +24,14 @@ const fileFilter = (req, file, cb) => { ...@@ -24,7 +24,14 @@ const fileFilter = (req, file, cb) => {
'application/vnd.ms-excel', // .xls 'application/vnd.ms-excel', // .xls
'application/pdf', // .pdf 'application/pdf', // .pdf
'application/msword', // .doc 'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/zip', // .zip
'application/x-zip-compressed', // .zip (Windows)
'application/x-rar-compressed', // .rar
'application/octet-stream', // generic binary (fallback for zip)
'image/png',
'image/jpeg',
'image/jpg'
]; ];
if ( if (
...@@ -33,11 +40,16 @@ const fileFilter = (req, file, cb) => { ...@@ -33,11 +40,16 @@ const fileFilter = (req, file, cb) => {
file.originalname.endsWith('.xls') || file.originalname.endsWith('.xls') ||
file.originalname.endsWith('.pdf') || file.originalname.endsWith('.pdf') ||
file.originalname.endsWith('.doc') || file.originalname.endsWith('.doc') ||
file.originalname.endsWith('.docx') file.originalname.endsWith('.docx') ||
file.originalname.endsWith('.zip') ||
file.originalname.endsWith('.rar') ||
file.originalname.endsWith('.png') ||
file.originalname.endsWith('.jpg') ||
file.originalname.endsWith('.jpeg')
) { ) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only Excel, PDF, and Word files are allowed'), false); cb(new Error('Only Excel, PDF, Word, ZIP/RAR, and Image files are allowed'), false);
} }
}; };
...@@ -45,7 +57,7 @@ const upload = multer({ ...@@ -45,7 +57,7 @@ const upload = multer({
storage: storage, storage: storage,
fileFilter: fileFilter, fileFilter: fileFilter,
limits: { limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit fileSize: 25 * 1024 * 1024 // 25MB limit (for coding round zips)
} }
}); });
......
const mongoose = require('mongoose');
const evaluationSchema = new mongoose.Schema({
evaluatorId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
evaluatorRole: { type: String, enum: ['admin', 'hr', 'pm', 'interviewer'], required: true },
comments: { type: String, default: '' },
recommendation: {
type: String,
enum: ['offer', 'on_hold', 'rejected', '2nd_round'],
required: true
},
date: { type: Date, default: Date.now }
}, { _id: true });
const interviewSchema = new mongoose.Schema({
// Candidate info
candidateId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
interviewerId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, // legacy
assignedInterviewers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
assignedHRs: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
assignedPMs: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
// Interview metadata
position: { type: String, required: true, trim: true },
techStack: { type: String, default: '', trim: true },
source: { type: String, default: '', trim: true },
dateOfInterview: { type: Date, default: Date.now },
// Quiz assignments (aptitude + technical)
quizzes: [{
quizId: { type: mongoose.Schema.Types.ObjectId, ref: 'Quiz' },
title: { type: String },
score: { type: Number, default: null },
totalMarks: { type: Number, default: null },
percentage: { type: Number, default: null },
completed: { type: Boolean, default: false }
}],
// Coding round
codingRound: {
zipFile: { type: String, default: '' },
submittedAt: { type: Date },
validated: { type: Boolean, default: false },
validatedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
},
// Evaluations from PM, Interviewer, HR, Admin
evaluations: [evaluationSchema],
// Final decision
finalDecision: {
type: String,
enum: ['pending', 'accepted', 'rejected', 'on_hold', '2nd_round'],
default: 'pending'
},
decidedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
decidedAt: { type: Date },
// Interview status/phase
status: {
type: String,
enum: ['pending', 'quiz_phase', 'coding_phase', 'evaluation', 'completed'],
default: 'pending'
},
// Type
type: {
type: String,
enum: ['individual', 'group'],
default: 'individual'
},
// For group interviews
groupId: { type: String, default: '' },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
}, {
timestamps: true
});
module.exports = mongoose.model('Interview', interviewSchema);
...@@ -23,6 +23,10 @@ const userSchema = new mongoose.Schema({ ...@@ -23,6 +23,10 @@ const userSchema = new mongoose.Schema({
type: String, type: String,
default: '' default: ''
}, },
signature: {
type: String,
default: ''
},
password: { password: {
type: String, type: String,
required: [true, 'Password is required'], required: [true, 'Password is required'],
...@@ -30,7 +34,7 @@ const userSchema = new mongoose.Schema({ ...@@ -30,7 +34,7 @@ const userSchema = new mongoose.Schema({
}, },
role: { role: {
type: String, type: String,
enum: ['admin', 'hr', 'candidate'], enum: ['admin', 'hr', 'candidate', 'pm', 'interviewer'],
default: 'candidate' default: 'candidate'
}, },
group: { group: {
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
router.get('/users', async (req, res) => { router.get('/users', async (req, res) => {
try { try {
const { role } = req.query; const { role } = req.query;
const filter = role ? { role } : { role: { $in: ['candidate', 'hr'] } }; const filter = role ? { role } : { role: { $in: ['candidate', 'hr', 'pm', 'interviewer'] } };
const users = await User.find(filter) const users = await User.find(filter)
.select('-password') .select('-password')
.sort({ createdAt: -1 }); .sort({ createdAt: -1 });
...@@ -47,15 +47,20 @@ ...@@ -47,15 +47,20 @@
} }
}); });
// @route POST /api/admin/users/create-hr // @route POST /api/admin/users/create-staff
// @desc Create an HR user // @desc Create a staff user (HR, PM, Interviewer)
// @access Admin // @access Admin
router.post('/users/create-hr', async (req, res) => { router.post('/users/create-staff', async (req, res) => {
try { try {
const { name, email, password } = req.body; const { name, email, password, role } = req.body;
if (!name || !email || !password) { if (!name || !email || !password || !role) {
return res.status(400).json({ message: 'Please provide name, email, and password' }); return res.status(400).json({ message: 'Please provide name, email, password, and role' });
}
const validRoles = ['hr', 'pm', 'interviewer'];
if (!validRoles.includes(role)) {
return res.status(400).json({ message: 'Invalid role' });
} }
const existingUser = await User.findOne({ email }); const existingUser = await User.findOne({ email });
...@@ -63,10 +68,10 @@ ...@@ -63,10 +68,10 @@
return res.status(400).json({ message: 'User with this email already exists' }); return res.status(400).json({ message: 'User with this email already exists' });
} }
const user = await User.create({ name, email, password, role: 'hr' }); const user = await User.create({ name, email, password, role });
res.status(201).json({ res.status(201).json({
message: 'HR user created successfully', message: 'Staff user created successfully',
user: { user: {
id: user._id, id: user._id,
name: user.name, name: user.name,
...@@ -203,6 +208,30 @@ ...@@ -203,6 +208,30 @@
} }
}); });
// @route GET /api/admin/users/:userId/interviews
// @desc Get all interviews assigned to a specific user
// @access Admin
router.get('/users/:userId/interviews', async (req, res) => {
try {
const { userId } = req.params;
const Interview = require('../models/Interview');
const user = await User.findById(userId).select('-password');
if (!user) return res.status(404).json({ message: 'User not found' });
const interviews = await Interview.find({ candidateId: userId })
.populate('interviewerId', 'name email role')
.populate('createdBy', 'name')
.populate('decidedBy', 'name')
.populate('quizzes.quizId', 'title category difficulty timer totalQuestions')
.sort({ createdAt: -1 });
res.json({ user, interviews });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/admin/submissions/:submissionId // @route GET /api/admin/submissions/:submissionId
// @desc Get detailed submission - answers, correct answers, scores // @desc Get detailed submission - answers, correct answers, scores
// @access Admin // @access Admin
......
...@@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken'); ...@@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken');
const User = require('../models/User'); const User = require('../models/User');
const Group = require('../models/Group'); const Group = require('../models/Group');
const { protect } = require('../middleware/auth'); const { protect } = require('../middleware/auth');
const upload = require('../middleware/upload');
const fs = require('fs');
const path = require('path');
const router = express.Router(); const router = express.Router();
// Generate JWT Token // Generate JWT Token
...@@ -119,6 +122,32 @@ router.get('/me', protect, async (req, res) => { ...@@ -119,6 +122,32 @@ router.get('/me', protect, async (req, res) => {
} }
}); });
// @route POST /api/auth/signature
// @desc Upload digital signature image
// @access Private
router.post('/signature', protect, upload.single('signature'), async (req, res) => {
try {
const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (!req.file) {
return res.status(400).json({ message: 'Please upload an image file' });
}
if (user.signature) {
const oldPath = path.join(__dirname, '..', 'uploads', path.basename(user.signature));
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
user.signature = `/uploads/${req.file.filename}`;
await user.save();
res.json({ message: 'Signature uploaded successfully', signature: user.signature });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/auth/groups // @route GET /api/auth/groups
// @desc Get all groups for registration // @desc Get all groups for registration
// @access Public // @access Public
...@@ -135,4 +164,26 @@ router.get('/groups', async (req, res) => { ...@@ -135,4 +164,26 @@ router.get('/groups', async (req, res) => {
} }
}); });
// @route PUT /api/auth/profile
// @desc Update current user profile
// @access Private
router.put('/profile', protect, upload.none(), async (req, res) => {
try {
const { name, email, phoneNumber } = req.body;
const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (name) user.name = name;
if (email) user.email = email;
if (phoneNumber !== undefined) user.phoneNumber = phoneNumber;
await user.save();
res.json({ message: 'Profile updated', user });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router; module.exports = router;
...@@ -12,6 +12,24 @@ const router = express.Router(); ...@@ -12,6 +12,24 @@ const router = express.Router();
// All candidate routes require authentication + candidate role // All candidate routes require authentication + candidate role
router.use(protect, authorize('candidate')); router.use(protect, authorize('candidate'));
// @route GET /api/candidate/interviews
// @desc Get assigned interviews for the candidate
// @access Candidate
router.get('/interviews', async (req, res) => {
try {
const Interview = require('../models/Interview');
const interviews = await Interview.find({ candidateId: req.user._id })
.populate('interviewerId', 'name email')
.populate('createdBy', 'name')
.populate('quizzes.quizId', 'title timer totalQuestions category difficulty')
.sort({ createdAt: -1 });
res.json({ interviews });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/quizzes // @route GET /api/candidate/quizzes
// @desc Get assigned and available quizzes for the candidate // @desc Get assigned and available quizzes for the candidate
// @access Candidate // @access Candidate
...@@ -86,9 +104,18 @@ router.get('/quiz/:quizId', async (req, res) => { ...@@ -86,9 +104,18 @@ router.get('/quiz/:quizId', async (req, res) => {
// Check assignment - is this candidate allowed to take this quiz? // Check assignment - is this candidate allowed to take this quiz?
const user = await User.findById(req.user._id); const user = await User.findById(req.user._id);
// Check if part of interview
const Interview = require('../models/Interview');
const interview = await Interview.findOne({
candidateId: req.user._id,
'quizzes.quizId': quizId
});
const isAssigned = quiz.assignToAll || const isAssigned = quiz.assignToAll ||
quiz.assignees.some(a => a.toString() === req.user._id.toString()) || quiz.assignees.some(a => a.toString() === req.user._id.toString()) ||
quiz.assignedGroups.includes(user.group); quiz.assignedGroups.includes(user.group) ||
!!interview;
if (!isAssigned) { if (!isAssigned) {
return res.status(403).json({ message: 'You are not assigned to this quiz' }); return res.status(403).json({ message: 'You are not assigned to this quiz' });
...@@ -168,6 +195,20 @@ router.post('/quiz/:quizId/submit', async (req, res) => { ...@@ -168,6 +195,20 @@ router.post('/quiz/:quizId/submit', async (req, res) => {
timeTaken: timeTaken || 0 timeTaken: timeTaken || 0
}); });
// Update Interview if applicable
const Interview = require('../models/Interview');
await Interview.findOneAndUpdate(
{ candidateId: req.user._id, 'quizzes.quizId': quizId },
{
$set: {
'quizzes.$.score': score,
'quizzes.$.totalMarks': totalMarks,
'quizzes.$.percentage': percentage,
'quizzes.$.completed': true
}
}
);
res.status(201).json({ res.status(201).json({
message: 'Quiz submitted successfully', message: 'Quiz submitted successfully',
result: { result: {
...@@ -257,6 +298,44 @@ function checkAnswersMatch(arr1, arr2) { ...@@ -257,6 +298,44 @@ function checkAnswersMatch(arr1, arr2) {
return a === b; return a === b;
} }
// @route POST /api/candidate/interview/:interviewId/coding
// @desc Submit coding round zip file
// @access Candidate
router.post('/interview/:interviewId/coding', upload.single('codingZip'), async (req, res) => {
try {
const Interview = require('../models/Interview');
const { interviewId } = req.params;
const interview = await Interview.findOne({ _id: interviewId, candidateId: req.user._id });
if (!interview) {
return res.status(404).json({ message: 'Interview not found' });
}
if (!req.file) {
return res.status(400).json({ message: 'Please upload a ZIP file' });
}
interview.codingRound = {
zipFile: `/uploads/${req.file.filename}`,
submittedAt: Date.now(),
validated: false
};
// Update status to coding phase evaluation
if (interview.status === 'quiz_phase' || interview.status === 'pending') {
interview.status = 'evaluation';
} else {
interview.status = 'evaluation'; // Since they submitted, it needs eval
}
await interview.save();
res.json({ message: 'Coding round submitted successfully', interview });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route GET /api/candidate/profile // @route GET /api/candidate/profile
// @desc Get candidate profile including topics of interest // @desc Get candidate profile including topics of interest
// @access Candidate // @access Candidate
......
This diff is collapsed.
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
app.use('/api/admin', require('./routes/admin')); app.use('/api/admin', require('./routes/admin'));
app.use('/api/hr', require('./routes/hr')); app.use('/api/hr', require('./routes/hr'));
app.use('/api/candidate', require('./routes/candidate')); app.use('/api/candidate', require('./routes/candidate'));
app.use('/api/interview', require('./routes/interview'));
// Keep backward compatibility for old student endpoints // Keep backward compatibility for old student endpoints
app.use('/api/student', require('./routes/candidate')); app.use('/api/student', require('./routes/candidate'));
......
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { adminGuard, hrGuard, candidateGuard, guestGuard } from './guards/auth.guard'; import { adminGuard, hrGuard, candidateGuard, guestGuard, pmGuard, interviewerGuard } from './guards/auth.guard';
import { LayoutComponent } from './components/layout/layout'; import { LayoutComponent } from './components/layout/layout';
export const routes: Routes = [ export const routes: Routes = [
...@@ -42,13 +42,21 @@ export const routes: Routes = [ ...@@ -42,13 +42,21 @@ export const routes: Routes = [
loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent) loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent)
}, },
{ {
path: 'hr-users', path: 'staff/:role',
loadComponent: () => import('./pages/admin/hr-users/hr-users').then(m => m.AdminHrUsersComponent) loadComponent: () => import('./pages/admin/hr-users/hr-users').then(m => m.AdminHrUsersComponent)
}, },
{ {
path: 'submissions/:submissionId', path: 'submissions/:submissionId',
loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent) loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent)
}, },
{
path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'group-interview',
loadComponent: () => import('./pages/admin/group-interview/group-interview').then(m => m.GroupInterviewComponent)
},
{ {
path: 'quizzes', path: 'quizzes',
loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent) loadComponent: () => import('./pages/admin/quizzes/quizzes').then(m => m.AdminQuizzesComponent)
...@@ -65,6 +73,10 @@ export const routes: Routes = [ ...@@ -65,6 +73,10 @@ export const routes: Routes = [
path: 'quiz/:quizId/edit', path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/admin/edit-quiz/edit-quiz').then(m => m.EditQuizComponent) loadComponent: () => import('./pages/admin/edit-quiz/edit-quiz').then(m => m.EditQuizComponent)
}, },
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
] ]
}, },
...@@ -111,6 +123,58 @@ export const routes: Routes = [ ...@@ -111,6 +123,58 @@ export const routes: Routes = [
path: 'quiz/:quizId/edit', path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/hr/edit-quiz/edit-quiz').then(m => m.HREditQuizComponent) loadComponent: () => import('./pages/hr/edit-quiz/edit-quiz').then(m => m.HREditQuizComponent)
}, },
{
path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
]
},
// ========== PROJECT MANAGER ROUTES ==========
{
path: 'pm',
component: LayoutComponent,
canActivate: [pmGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'interviews',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
]
},
// ========== INTERVIEWER ROUTES ==========
{
path: 'interviewer',
component: LayoutComponent,
canActivate: [interviewerGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'interviews',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
},
{
path: 'profile',
loadComponent: () => import('./pages/staff/profile/profile').then(m => m.StaffProfileComponent)
},
] ]
}, },
......
...@@ -72,6 +72,16 @@ ...@@ -72,6 +72,16 @@
color: #fff; color: #fff;
} }
.role-pm {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #fff;
}
.role-interviewer {
background: linear-gradient(135deg, #06b6d4, #0891b2);
color: #fff;
}
/* Navigation items */ /* Navigation items */
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
...@@ -354,6 +364,7 @@ ...@@ -354,6 +364,7 @@
} }
/* Glassy Modal Styles */ /* Glassy Modal Styles */
.glassy-overlay { .glassy-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
...@@ -375,11 +386,13 @@ ...@@ -375,11 +386,13 @@
border-radius: 20px; border-radius: 20px;
width: 90%; width: 90%;
max-width: 550px; max-width: 550px;
max-height: 80vh;
padding: 32px; padding: 32px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1); box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
position: relative; position: relative;
overflow: hidden; display: flex;
flex-direction: column;
} }
.modal-header { .modal-header {
...@@ -415,6 +428,9 @@ ...@@ -415,6 +428,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
overflow-y: auto;
padding-right: 8px; /* Space for scrollbar */
padding-bottom: 8px;
} }
.glassy-option { .glassy-option {
......
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
<div class="mobile-actions"> <div class="mobile-actions">
<button class="icon-btn" (click)="toggleThemeMenu()"> <button class="icon-btn" (click)="toggleThemeMenu()">
<span class="material-symbols-rounded"> <span class="material-symbols-rounded">
{{ themeService.currentTheme() === 'light' ? 'light_mode' : themeService.currentTheme() === 'dark' ? 'dark_mode' : 'water' }} {{ themeService.currentTheme() === 'light' ? 'light_mode' : themeService.currentTheme() === 'dark' ?
'dark_mode' : 'water' }}
</span> </span>
</button> </button>
</div> </div>
...@@ -44,10 +45,8 @@ ...@@ -44,10 +45,8 @@
<span class="nav-label">{{ item.label }}</span> <span class="nav-label">{{ item.label }}</span>
</a> </a>
} @else { } @else {
<a [routerLink]="item.route" <a [routerLink]="item.route" routerLinkActive="active"
routerLinkActive="active" [routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}" class="nav-item"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}"
class="nav-item"
(click)="mobileSidebarOpen = false"> (click)="mobileSidebarOpen = false">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span> <span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span> <span class="nav-label">{{ item.label }}</span>
...@@ -61,10 +60,7 @@ ...@@ -61,10 +60,7 @@
<div class="section-label">Theme</div> <div class="section-label">Theme</div>
<div class="theme-switcher"> <div class="theme-switcher">
@for (t of themes; track t.id) { @for (t of themes; track t.id) {
<button <button class="theme-btn" [class.active]="themeService.currentTheme() === t.id" (click)="setTheme(t.id)"
class="theme-btn"
[class.active]="themeService.currentTheme() === t.id"
(click)="setTheme(t.id)"
[title]="t.label"> [title]="t.label">
<span class="material-symbols-rounded">{{ t.icon }}</span> <span class="material-symbols-rounded">{{ t.icon }}</span>
</button> </button>
...@@ -81,6 +77,7 @@ ...@@ -81,6 +77,7 @@
<span class="user-email">{{ authService.currentUser()?.email }}</span> <span class="user-email">{{ authService.currentUser()?.email }}</span>
</div> </div>
</div> </div>
<button class="logout-btn" (click)="logout()"> <button class="logout-btn" (click)="logout()">
<span class="material-symbols-rounded">logout</span> <span class="material-symbols-rounded">logout</span>
<span>Sign Out</span> <span>Sign Out</span>
...@@ -97,7 +94,7 @@ ...@@ -97,7 +94,7 @@
<!-- Manage Users Glassy Popup --> <!-- Manage Users Glassy Popup -->
@if (uiService.showManageUsersPopup()) { @if (uiService.showManageUsersPopup()) {
<div class="glassy-overlay" (click)="closeManageUsersPopup()"> <div class="glassy-overlay" (click)="closeManageUsersPopup()">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()"> <div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
<h2>Manage Users & Groups</h2> <h2>Manage Users & Groups</h2>
...@@ -130,7 +127,7 @@ ...@@ -130,7 +127,7 @@
</a> </a>
@if (authService.getUserRole() === 'admin') { @if (authService.getUserRole() === 'admin') {
<a routerLink="/admin/hr-users" class="glassy-option" (click)="closeManageUsersPopup()"> <a routerLink="/admin/staff/hr" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper"> <div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">assignment_ind</span> <span class="material-symbols-rounded block-icon">assignment_ind</span>
</div> </div>
...@@ -140,8 +137,85 @@ ...@@ -140,8 +137,85 @@
</div> </div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span> <span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a> </a>
<a routerLink="/admin/staff/pm" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">work</span>
</div>
<div class="option-text">
<h3>Project Managers</h3>
<p>Manage PMs who assign and track interviews</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
<a routerLink="/admin/staff/interviewer" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">person_search</span>
</div>
<div class="option-text">
<h3>Interviewers</h3>
<p>Manage technical and HR interviewers</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
} }
</div> </div>
</div> </div>
</div>
}
@if (uiService.showInterviewPopup()) {
<div class="glassy-overlay" (click)="uiService.showInterviewPopup.set(false)">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Interview Management</h2>
<button class="close-btn" (click)="uiService.showInterviewPopup.set(false)">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-options horizontal-options">
<a routerLink="/admin/individual-interview" class="glassy-option"
(click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">
person
</span>
</div>
<div class="option-text">
<h3>Individual Interview</h3>
<p>Evaluate technical and one-to-one interviews</p>
</div> </div>
<span class="material-symbols-rounded arrow-icon">
arrow_forward
</span>
</a>
<a routerLink="/admin/group-interview" class="glassy-option" (click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">
groups
</span>
</div>
<div class="option-text">
<h3>Group Interview</h3>
<p>Evaluate group discussion and teamwork rounds</p>
</div>
<span class="material-symbols-rounded arrow-icon">
arrow_forward
</span>
</a>
</div>
</div>
</div>
} }
\ No newline at end of file
...@@ -42,6 +42,7 @@ export class LayoutComponent { ...@@ -42,6 +42,7 @@ export class LayoutComponent {
{ icon: 'group', label: 'Users', route: '/admin/users' }, { icon: 'group', label: 'Users', route: '/admin/users' },
{ icon: 'quiz', label: 'Quizzes', route: '/admin/quizzes' }, { icon: 'quiz', label: 'Quizzes', route: '/admin/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/admin/create-quiz' }, { icon: 'add_circle', label: 'Create Quiz', route: '/admin/create-quiz' },
{ icon: 'person', label: 'Profile', route: '/admin/profile' },
]; ];
case 'hr': case 'hr':
return [ return [
...@@ -49,6 +50,20 @@ export class LayoutComponent { ...@@ -49,6 +50,20 @@ export class LayoutComponent {
{ icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' }, { icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' }, { icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' },
{ icon: 'people', label: 'Candidates', route: '/hr/users' }, { icon: 'people', label: 'Candidates', route: '/hr/users' },
{ icon: 'work', label: 'Interviews', route: '/hr/individual-interview' },
{ icon: 'person', label: 'Profile', route: '/hr/profile' },
];
case 'pm':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/pm/dashboard' },
{ icon: 'work', label: 'Interviews', route: '/pm/interviews' },
{ icon: 'person', label: 'Profile', route: '/pm/profile' },
];
case 'interviewer':
return [
{ icon: 'dashboard', label: 'Dashboard', route: '/interviewer/dashboard' },
{ icon: 'assignment_ind', label: 'My Interviews', route: '/interviewer/interviews' },
{ icon: 'person', label: 'Profile', route: '/interviewer/profile' },
]; ];
case 'candidate': case 'candidate':
return [ return [
...@@ -66,6 +81,8 @@ export class LayoutComponent { ...@@ -66,6 +81,8 @@ export class LayoutComponent {
case 'admin': return { label: 'Admin', class: 'role-admin' }; case 'admin': return { label: 'Admin', class: 'role-admin' };
case 'hr': return { label: 'HR', class: 'role-hr' }; case 'hr': return { label: 'HR', class: 'role-hr' };
case 'candidate': return { label: 'Candidate', class: 'role-candidate' }; case 'candidate': return { label: 'Candidate', class: 'role-candidate' };
case 'pm': return { label: 'PM', class: 'role-pm' };
case 'interviewer': return { label: 'Interviewer', class: 'role-interviewer' };
default: return { label: '', class: '' }; default: return { label: '', class: '' };
} }
}); });
......
...@@ -99,3 +99,39 @@ export const guestGuard: CanActivateFn = () => { ...@@ -99,3 +99,39 @@ export const guestGuard: CanActivateFn = () => {
return true; return true;
}; };
// Project Manager only
export const pmGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
if (authService.getUserRole() !== 'pm') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
// Interviewer only
export const interviewerGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (!authService.isLoggedIn()) {
router.navigate(['/login']);
return false;
}
if (authService.getUserRole() !== 'interviewer') {
router.navigate([authService.getDashboardRoute()]);
return false;
}
return true;
};
...@@ -110,22 +110,24 @@ ...@@ -110,22 +110,24 @@
/* Quick Actions */ /* Quick Actions */
.actions-grid { .actions-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(4,1fr);
gap: 16px; gap: 16px;
} }
.action-card { .action-card {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 16px; gap: 16px;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
height: 100%;
} }
.action-icon { .action-icon {
font-size: 28px; font-size: 28px;
color: var(--accent-primary); color: var(--accent-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
} }
.action-info { .action-info {
...@@ -143,12 +145,19 @@ ...@@ -143,12 +145,19 @@
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 38px;
} }
.action-arrow { .action-arrow {
color: var(--text-muted); color: var(--text-muted);
font-size: 20px; font-size: 20px;
transition: transform 0.2s; transition: transform 0.2s;
margin-top: 5px;
} }
.action-card:hover .action-arrow { .action-card:hover .action-arrow {
......
...@@ -70,6 +70,14 @@ ...@@ -70,6 +70,14 @@
<div class="section"> <div class="section">
<h2 class="section-title">Quick Actions</h2> <h2 class="section-title">Quick Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<a (click)="openInterviewPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">groups</span>
<div class="action-info">
<h3>Interviews</h3>
<p>Manage group and individual interview evaluations</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;"> <a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span> <span class="material-symbols-rounded action-icon">group</span>
<div class="action-info"> <div class="action-info">
......
...@@ -32,4 +32,9 @@ export class AdminDashboardComponent implements OnInit { ...@@ -32,4 +32,9 @@ export class AdminDashboardComponent implements OnInit {
openUsersPopup(): void { openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true); this.uiService.showManageUsersPopup.set(true);
} }
openInterviewPopup(): void {
this.uiService.showInterviewPopup.set(true);
}
} }
import { Component } from '@angular/core';
@Component({
selector: 'app-group-interview',
imports: [],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css',
})
export class GroupInterviewComponent{}
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="page-header"> <div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<h1>HR Users</h1> <button class="icon-btn" (click)="goBack()" style="background: var(--bg-card); border: 1px solid var(--border-color); padding: 8px; border-radius: 12px; cursor: pointer; color: var(--text-primary); transition: all 0.2s; display: flex; align-items: center; justify-content: center;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='var(--bg-card)'">
<p class="page-subtitle">Manage HR Staff platform access and identity</p> <span class="material-symbols-rounded">arrow_back</span>
</button>
<div>
<h1 style="margin: 0; font-size: 24px;">{{ getRoleDisplay() }}s</h1>
<p class="page-subtitle" style="margin: 4px 0 0;">Manage {{ getRoleDisplay() }} platform access and identity</p>
</div>
</div> </div>
@if (error()) { @if (error()) {
...@@ -12,7 +17,7 @@ ...@@ -12,7 +17,7 @@
} }
<div class="card card-padding form-card" style="margin-bottom: 40px;"> <div class="card card-padding form-card" style="margin-bottom: 40px;">
<h2 class="section-title">Add HR User</h2> <h2 class="section-title">Add {{ getRoleDisplay() }}</h2>
<form (ngSubmit)="createHRUser()" class="group-form"> <form (ngSubmit)="createHRUser()" class="group-form">
<div class="form-group row-align"> <div class="form-group row-align">
<div class="input-container"> <div class="input-container">
...@@ -32,14 +37,14 @@ ...@@ -32,14 +37,14 @@
@if (creating()) { @if (creating()) {
<span class="spinner"></span> <span class="spinner"></span>
} @else { } @else {
<span class="material-symbols-rounded">person_add</span> Add HR <span class="material-symbols-rounded">person_add</span> Add {{ getRoleDisplay() }}
} }
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<h2 class="section-title">Existing HR Users</h2> <h2 class="section-title">Existing {{ getRoleDisplay() }}s</h2>
@if (loading()) { @if (loading()) {
<div class="loading-state"> <div class="loading-state">
<div class="loader"></div> <div class="loader"></div>
...@@ -47,8 +52,8 @@ ...@@ -47,8 +52,8 @@
} @else if (hrUsers().length === 0) { } @else if (hrUsers().length === 0) {
<div class="empty-state"> <div class="empty-state">
<span class="material-symbols-rounded empty-icon">manage_accounts</span> <span class="material-symbols-rounded empty-icon">manage_accounts</span>
<h3>No HR users available</h3> <h3>No {{ getRoleDisplay() }}s available</h3>
<p>Add your first HR user above</p> <p>Add your first {{ getRoleDisplay() }} above</p>
</div> </div>
} @else { } @else {
<div class="groups-grid"> <div class="groups-grid">
......
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
...@@ -18,10 +19,11 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -18,10 +19,11 @@ export class AdminHrUsersComponent implements OnInit {
error = signal<string>(''); error = signal<string>('');
success = signal<string>(''); success = signal<string>('');
// Create New HR // Create New Staff
newName = signal(''); newName = signal('');
newEmail = signal(''); newEmail = signal('');
newPassword = signal(''); newPassword = signal('');
currentRole = signal('hr');
creating = signal(false); creating = signal(false);
// Edit HR // Edit HR
...@@ -31,17 +33,29 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -31,17 +33,29 @@ export class AdminHrUsersComponent implements OnInit {
editPassword = signal(''); editPassword = signal('');
saving = signal(false); saving = signal(false);
constructor(private quizService: QuizService) {} constructor(private quizService: QuizService, private route: ActivatedRoute, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(params => {
const role = params.get('role');
if (role) {
this.currentRole.set(role);
}
this.loadHRUsers(); this.loadHRUsers();
});
} }
loadHRUsers(): void { loadHRUsers(): void {
this.loading.set(true); this.loading.set(true);
this.quizService.getUsers('hr').subscribe({ this.quizService.getUsers().subscribe({
next: (res) => { next: (res) => {
this.hrUsers.set(res.users); // Filter users based on current role
const staff = res.users.filter((u: any) => u.role === this.currentRole());
this.hrUsers.set(staff);
this.loading.set(false); this.loading.set(false);
}, },
error: () => this.loading.set(false) error: () => this.loading.set(false)
...@@ -50,7 +64,7 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -50,7 +64,7 @@ export class AdminHrUsersComponent implements OnInit {
createHRUser(): void { createHRUser(): void {
if (!this.newName().trim() || !this.newEmail().trim() || !this.newPassword().trim()) { if (!this.newName().trim() || !this.newEmail().trim() || !this.newPassword().trim()) {
this.error.set('Please fill out name, email, and password'); this.error.set('Please fill out all fields');
return; return;
} }
...@@ -58,13 +72,14 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -58,13 +72,14 @@ export class AdminHrUsersComponent implements OnInit {
this.error.set(''); this.error.set('');
this.success.set(''); this.success.set('');
this.quizService.createHRUser({ this.quizService.createStaffUser({
name: this.newName().trim(), name: this.newName().trim(),
email: this.newEmail().trim(), email: this.newEmail().trim(),
password: this.newPassword().trim() password: this.newPassword().trim(),
role: this.currentRole()
}).subscribe({ }).subscribe({
next: () => { next: () => {
this.success.set('HR User created successfully!'); this.success.set('Staff User created successfully!');
this.creating.set(false); this.creating.set(false);
this.newName.set(''); this.newName.set('');
this.newEmail.set(''); this.newEmail.set('');
...@@ -72,7 +87,7 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -72,7 +87,7 @@ export class AdminHrUsersComponent implements OnInit {
this.loadHRUsers(); this.loadHRUsers();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to create HR user'); this.error.set(err.error?.message || 'Failed to create staff user');
this.creating.set(false); this.creating.set(false);
} }
}); });
...@@ -101,32 +116,39 @@ export class AdminHrUsersComponent implements OnInit { ...@@ -101,32 +116,39 @@ export class AdminHrUsersComponent implements OnInit {
this.quizService.editUser(userId, data).subscribe({ this.quizService.editUser(userId, data).subscribe({
next: () => { next: () => {
this.success.set('HR User updated successfully!'); this.success.set('Staff User updated successfully!');
this.saving.set(false); this.saving.set(false);
this.cancelEdit(); this.cancelEdit();
this.loadHRUsers(); this.loadHRUsers();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to update HR user'); this.error.set(err.error?.message || 'Failed to update staff user');
this.saving.set(false); this.saving.set(false);
} }
}); });
} }
deleteHRUser(userId: string): void { deleteHRUser(userId: string): void {
if (confirm('Are you sure you want to delete this HR server? This revokes access entirely.')) { if (confirm('Are you sure you want to delete this staff user? This revokes access entirely.')) {
this.error.set(''); this.error.set('');
this.success.set(''); this.success.set('');
this.quizService.deleteUser(userId).subscribe({ this.quizService.deleteUser(userId).subscribe({
next: () => { next: () => {
this.success.set('HR User deleted successfully!'); this.success.set('Staff User deleted successfully!');
this.loadHRUsers(); this.loadHRUsers();
}, },
error: (err) => { error: (err) => {
this.error.set(err.error?.message || 'Failed to delete HR user'); this.error.set(err.error?.message || 'Failed to delete staff user');
} }
}); });
} }
} }
getRoleDisplay(): string {
const role = this.currentRole();
if (role === 'hr') return 'HR User';
if (role === 'pm') return 'Project Manager';
if (role === 'interviewer') return 'Interviewer';
return 'Staff User';
}
} }
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { QuizService } from '../../../services/quiz.service';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-individual-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css'
})
export class IndividualInterviewComponent implements OnInit {
interviews = signal<any[]>([]);
loading = signal(true);
showCreateModal = signal(false);
showDetailModal = signal(false);
selectedInterview = signal<any>(null);
// Create form data
candidates = signal<any[]>([]);
interviewers = signal<any[]>([]);
hrs = signal<any[]>([]);
pms = signal<any[]>([]);
quizzes = signal<any[]>([]);
newInterview = {
candidateId: '',
assignedInterviewers: [] as string[],
assignedHRs: [] as string[],
assignedPMs: [] as string[],
position: '',
techStack: '',
source: '',
dateOfInterview: new Date().toISOString().split('T')[0],
quizIds: [] as string[]
};
// Evaluation form
evalComment = signal('');
evalRecommendation = signal('');
// Stats
stats = signal<any>({ total: 0, pending: 0, completed: 0, accepted: 0, rejected: 0 });
// Filter
filterStatus = signal('');
isSubmitting = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
ngOnInit(): void {
this.loadInterviews();
this.loadStats();
}
loadInterviews(): void {
this.loading.set(true);
const params: any = {};
if (this.filterStatus()) params.status = this.filterStatus();
params.type = 'individual';
this.quizService.getInterviews(params).subscribe({
next: (res) => {
this.interviews.set(res.interviews || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
loadStats(): void {
this.quizService.getInterviewStats().subscribe({
next: (res) => this.stats.set(res)
});
}
openCreateModal(): void {
// Load dropdown data
this.quizService.getInterviewCandidates().subscribe({
next: (res) => this.candidates.set(res.candidates || [])
});
this.quizService.getInterviewers().subscribe({
next: (res) => {
const staff = res.interviewers || [];
this.interviewers.set(staff.filter((s: any) => s.role === 'interviewer'));
this.pms.set(staff.filter((s: any) => s.role === 'pm'));
this.hrs.set(staff.filter((s: any) => s.role === 'hr'));
}
});
this.quizService.getAdminQuizzes().subscribe({
next: (res) => this.quizzes.set(res.quizzes || [])
});
this.newInterview = {
candidateId: '', assignedInterviewers: [], assignedHRs: [], assignedPMs: [], position: '', techStack: '',
source: '', dateOfInterview: new Date().toISOString().split('T')[0], quizIds: []
};
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
}
toggleQuizSelection(quizId: string): void {
const idx = this.newInterview.quizIds.indexOf(quizId);
if (idx >= 0) {
this.newInterview.quizIds.splice(idx, 1);
} else {
this.newInterview.quizIds.push(quizId);
}
}
toggleSelection(event: any, array: string[]): void {
const val = event.target.value;
if (event.target.checked) {
array.push(val);
} else {
const idx = array.indexOf(val);
if (idx >= 0) array.splice(idx, 1);
}
}
isQuizSelected(quizId: string): boolean {
return this.newInterview.quizIds.includes(quizId);
}
createInterview(): void {
if (!this.newInterview.candidateId || !this.newInterview.position) return;
this.isSubmitting.set(true);
this.quizService.createInterview(this.newInterview).subscribe({
next: () => {
this.isSubmitting.set(false);
this.closeCreateModal();
this.loadInterviews();
this.loadStats();
},
error: (err) => {
this.isSubmitting.set(false);
alert(err.error?.message || 'Failed to create interview');
}
});
}
openDetail(interview: any): void {
this.quizService.getInterviewById(interview._id).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.evalComment.set('');
this.evalRecommendation.set('');
this.showDetailModal.set(true);
}
});
}
closeDetail(): void {
this.showDetailModal.set(false);
this.selectedInterview.set(null);
}
submitEvaluation(): void {
const interview = this.selectedInterview();
if (!interview || !this.evalRecommendation()) return;
this.isSubmitting.set(true);
this.quizService.submitEvaluation(interview._id, {
comments: this.evalComment(),
recommendation: this.evalRecommendation()
}).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.evalComment.set('');
this.evalRecommendation.set('');
this.isSubmitting.set(false);
},
error: () => this.isSubmitting.set(false)
});
}
setDecision(decision: string): void {
const interview = this.selectedInterview();
if (!interview) return;
this.quizService.setInterviewDecision(interview._id, decision).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.loadInterviews();
this.loadStats();
}
});
}
deleteInterview(id: string): void {
if (confirm('Are you sure you want to delete this interview?')) {
this.quizService.deleteInterview(id).subscribe({
next: () => {
this.loadInterviews();
this.loadStats();
this.closeDetail();
}
});
}
}
onFilterChange(): void {
this.loadInterviews();
}
getStatusClass(status: string): string {
switch (status) {
case 'pending': return 'badge-warning';
case 'quiz_phase': return 'badge-info';
case 'coding_phase': return 'badge-info';
case 'evaluation': return 'badge-purple';
case 'completed': return 'badge-success';
default: return '';
}
}
getDecisionClass(decision: string): string {
switch (decision) {
case 'accepted': return 'badge-success';
case 'rejected': return 'badge-danger';
case 'on_hold': return 'badge-warning';
case '2nd_round': return 'badge-info';
default: return 'badge-muted';
}
}
formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
formatDecision(decision: string): string {
const map: any = {
pending: 'Pending', accepted: 'Accepted', rejected: 'Rejected',
on_hold: 'On Hold', '2nd_round': '2nd Round'
};
return map[decision] || decision;
}
hasUserEvaluated(): boolean {
const interview = this.selectedInterview();
if (!interview) return false;
const userId = this.authService.currentUser()?.id;
return interview.evaluations?.some((e: any) => e.evaluatorId?._id === userId);
}
isPdfGenerating = signal(false);
allEvaluationsDone(): boolean {
const iv = this.selectedInterview();
if (!iv) return false;
const numInterviewers = iv.assignedInterviewers?.length || 0;
const numHrs = iv.assignedHRs?.length || 0;
const numPms = iv.assignedPMs?.length || 0;
const totalExpected = numInterviewers + numHrs + numPms;
if (totalExpected === 0) return false;
const numEvaluations = iv.evaluations?.length || 0;
return numEvaluations >= totalExpected;
}
downloadEvaluationPdf(): void {
const iv = this.selectedInterview();
if (!iv) return;
this.isPdfGenerating.set(true);
setTimeout(() => {
window.print();
this.isPdfGenerating.set(false);
}, 500);
}
}
<div class="page-container animate-fade-in split-view" cdkDropListGroup> <div class="page-container animate-fade-in split-view" cdkDropListGroup>
<div class="main-workspace"> <div class="main-workspace">
<div class="page-header"> <div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<h1>Manage Groups</h1> <button class="icon-btn" (click)="goBack()" style="background: var(--bg-card); border: 1px solid var(--border-color); padding: 8px; border-radius: 12px; cursor: pointer; color: var(--text-primary); transition: all 0.2s; display: flex; align-items: center; justify-content: center;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='var(--bg-card)'">
<p class="page-subtitle">Create organizational groups and effortlessly drag-and-drop candidates into them.</p> <span class="material-symbols-rounded">arrow_back</span>
</button>
<div>
<h1 style="margin: 0; font-size: 24px;">Manage Groups</h1>
<p class="page-subtitle" style="margin: 4px 0 0;">Create organizational groups and effortlessly drag-and-drop candidates into them.</p>
</div>
</div> </div>
@if (error()) { @if (error()) {
......
import { Component, signal, OnInit, computed } from '@angular/core'; import { Component, signal, OnInit, computed } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service'; import { QuizService } from '../../../services/quiz.service';
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop'; import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
...@@ -31,7 +31,11 @@ export class ManageGroupsComponent implements OnInit { ...@@ -31,7 +31,11 @@ export class ManageGroupsComponent implements OnInit {
return this.allStudents().filter(s => !s.group || s.group === 'General'); return this.allStudents().filter(s => !s.group || s.group === 'General');
}); });
constructor(private quizService: QuizService) {} constructor(private quizService: QuizService, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void { ngOnInit(): void {
this.loadGroups(); this.loadGroups();
......
...@@ -3,10 +3,212 @@ ...@@ -3,10 +3,212 @@
max-width: 1400px; max-width: 1400px;
} }
/* ========== BREADCRUMB ========== */
.breadcrumb { .breadcrumb {
margin-bottom: 24px; margin-bottom: 24px;
} }
.breadcrumb-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.breadcrumb-link:hover { color: var(--accent-hover); }
.breadcrumb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
color: var(--accent-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.breadcrumb-btn:hover { color: var(--accent-hover); }
/* ========== SECTION HEADER ========== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
margin: 0;
}
.section-icon {
color: var(--accent-primary);
font-size: 22px;
}
.section-label-sm {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin: 0 0 4px;
}
/* ========== INTERVIEW COUNT BADGE ========== */
.interview-count-badge {
background: rgba(102,126,234,0.12);
color: var(--accent-primary);
padding: 4px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
/* ========== TABLE: INTERVIEW-SPECIFIC ELEMENTS ========== */
.interview-row {
cursor: pointer;
transition: background 0.15s;
}
.interview-row:hover { background: var(--bg-hover); }
.interview-seq {
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
background: var(--bg-hover);
padding: 4px 10px;
border-radius: 6px;
}
.interview-position {
font-weight: 600;
color: var(--text-primary);
}
.interviewer-cell {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.interviewer-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quiz-count-badge {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
/* ========== STATUS BADGE ========== */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-badge-lg {
font-size: 14px;
padding: 8px 16px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.status-pending {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.status-evaluation {
background: rgba(99, 102, 241, 0.12);
color: #6366f1;
}
.status-done {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
/* ========== QUIZ ID CODE BADGE ========== */
.quiz-id-badge {
font-family: 'Courier New', monospace;
font-size: 11px;
font-weight: 700;
background: rgba(102,126,234,0.1);
color: var(--accent-primary);
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
letter-spacing: 0.3px;
}
/* ========== DIFFICULTY BADGE ========== */
.diff-badge {
padding: 3px 10px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.diff-easy { background: rgba(34,197,94,0.12); color: #16a34a; }
.diff-medium { background: rgba(245,158,11,0.12); color: #d97706; }
.diff-hard { background: rgba(239,68,68,0.12); color: #dc2626; }
/* ========== NOT TAKEN ========== */
.not-taken {
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
.date-cell { color: var(--text-secondary); font-size: 13px; }
.breadcrumb a { .breadcrumb a {
color: #667eea; color: #667eea;
text-decoration: none; text-decoration: none;
......
...@@ -14,11 +14,20 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -14,11 +14,20 @@ import { QuizService } from '../../../services/quiz.service';
export class UserHistoryComponent implements OnInit { export class UserHistoryComponent implements OnInit {
userId = ''; userId = '';
user = signal<any>(null); user = signal<any>(null);
interviews = signal<any[]>([]);
// The interview the user has drilled into
selectedInterview = signal<any>(null);
// Submissions for the selected interview's quizzes
submissions = signal<any[]>([]); submissions = signal<any[]>([]);
loading = signal<boolean>(true); loadingInterviews = signal<boolean>(true);
loadingSubmissions = signal<boolean>(false);
currentLevel = signal<string>('beginner'); currentLevel = signal<string>('beginner');
toast = signal<{ message: string; type: 'success' | 'error' } | null>(null); toast = signal<{ message: string; type: 'success' | 'error' } | null>(null);
// Expose String for template
readonly String = String;
levels = [ levels = [
{ value: 'beginner', label: 'Fresher' }, { value: 'beginner', label: 'Fresher' },
{ value: 'intermediate', label: 'Intern' }, { value: 'intermediate', label: 'Intern' },
...@@ -26,6 +35,13 @@ export class UserHistoryComponent implements OnInit { ...@@ -26,6 +35,13 @@ export class UserHistoryComponent implements OnInit {
{ value: 'expert', label: 'Expert' } { value: 'expert', label: 'Expert' }
]; ];
/** Difficulty → level code mapping */
private levelCodeMap: Record<string, string> = {
easy: 'BEG',
medium: 'INT',
hard: 'ADV'
};
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
public authService: AuthService, public authService: AuthService,
...@@ -34,28 +50,63 @@ export class UserHistoryComponent implements OnInit { ...@@ -34,28 +50,63 @@ export class UserHistoryComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.userId = this.route.snapshot.params['userId']; this.userId = this.route.snapshot.params['userId'];
this.loadHistory(); this.loadInterviews();
} }
loadHistory(): void { loadInterviews(): void {
this.quizService.getUserHistory(this.userId).subscribe({ this.loadingInterviews.set(true);
this.quizService.getUserInterviews(this.userId).subscribe({
next: (res) => { next: (res) => {
this.user.set(res.user); this.user.set(res.user);
this.currentLevel.set(res.user.level || 'beginner'); this.currentLevel.set(res.user.level || 'beginner');
this.submissions.set(res.submissions); this.interviews.set(res.interviews);
this.loading.set(false); this.loadingInterviews.set(false);
}, },
error: () => this.loading.set(false) error: () => this.loadingInterviews.set(false)
}); });
} }
selectInterview(interview: any): void {
this.selectedInterview.set(interview);
// Load submissions for the quizzes in this interview
this.loadingSubmissions.set(true);
this.quizService.getUserHistory(this.userId).subscribe({
next: (res) => {
this.submissions.set(res.submissions || []);
this.loadingSubmissions.set(false);
},
error: () => this.loadingSubmissions.set(false)
});
}
goBackToList(): void {
this.selectedInterview.set(null);
this.submissions.set([]);
}
/** Find submission for a given quizId from the loaded submissions */
getSubmissionForQuiz(quizId: any): any {
if (!quizId) return null;
const qidStr = quizId.toString();
return this.submissions().find((s: any) =>
(s.quizId?._id?.toString() || s.quizId?.toString()) === qidStr
) || null;
}
/** Generate a quiz ID like Q_Aptitude_BEG_001 for a given quiz within an interview */
getQuizId(interviewQuiz: any, index: number): string {
const quizData = interviewQuiz.quizId || {};
const topic = (quizData.category || 'General').replace(/\s+/g, '_');
const diff = (quizData.difficulty || 'easy').toLowerCase();
const levelCode = this.levelCodeMap[diff] || 'BEG';
const seq = String(index + 1).padStart(3, '0');
return `Q_${topic}_${levelCode}_${seq}`;
}
changeLevel(newLevel: string): void { changeLevel(newLevel: string): void {
if (newLevel === this.currentLevel()) return; if (newLevel === this.currentLevel()) return;
const previousLevel = this.currentLevel(); this.quizService.updateUserLevel(this.userId, newLevel).subscribe({
const userRole = this.authService.currentUser()?.role || 'admin';
this.quizService.updateUserLevel(this.userId, newLevel, userRole).subscribe({
next: (res) => { next: (res) => {
this.currentLevel.set(newLevel); this.currentLevel.set(newLevel);
this.showToast( this.showToast(
...@@ -69,6 +120,24 @@ export class UserHistoryComponent implements OnInit { ...@@ -69,6 +120,24 @@ export class UserHistoryComponent implements OnInit {
}); });
} }
getStatusClass(status: string): string {
switch (status) {
case 'completed': return 'status-done';
case 'evaluation': return 'status-evaluation';
default: return 'status-pending';
}
}
getStatusLabel(status: string): string {
switch (status) {
case 'completed': return 'Done';
case 'evaluation': return 'Evaluation';
case 'coding_phase': return 'Coding Round';
case 'quiz_phase': return 'Quiz Phase';
default: return 'Pending';
}
}
private showToast(message: string, type: 'success' | 'error'): void { private showToast(message: string, type: 'success' | 'error'): void {
this.toast.set({ message, type }); this.toast.set({ message, type });
setTimeout(() => this.toast.set(null), 3500); setTimeout(() => this.toast.set(null), 3500);
...@@ -87,15 +156,10 @@ export class UserHistoryComponent implements OnInit { ...@@ -87,15 +156,10 @@ export class UserHistoryComponent implements OnInit {
getComfortLevel(topic: string): string { getComfortLevel(topic: string): string {
const u = this.user(); const u = this.user();
if (!u || !u.topicsOfInterest || !topic) return 'N/A'; if (!u || !u.topicsOfInterest || !topic) return 'N/A';
const interest = u.topicsOfInterest.find((t: any) => t.topic.toLowerCase() === topic.toLowerCase()); const interest = u.topicsOfInterest.find((t: any) => t.topic.toLowerCase() === topic.toLowerCase());
return interest ? `${interest.comfortLevel}%` : 'N/A'; return interest ? `${interest.comfortLevel}%` : 'N/A';
} }
logout(): void {
this.authService.logout();
}
getResumeUrl(resumePath: string): string { getResumeUrl(resumePath: string): string {
if (!resumePath) return ''; if (!resumePath) return '';
return `http://localhost:5000${resumePath}`; return `http://localhost:5000${resumePath}`;
......
<div class="page-container animate-fade-in"> <div class="page-container animate-fade-in">
<div class="page-header"> <div class="page-header" style="display: flex; align-items: center; gap: 16px;">
<h1>Student Users</h1> <button class="icon-btn" (click)="goBack()" style="background: var(--bg-card); border: 1px solid var(--border-color); padding: 8px; border-radius: 12px; cursor: pointer; color: var(--text-primary); transition: all 0.2s; display: flex; align-items: center; justify-content: center;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='var(--bg-card)'">
<p>View and manage registered students</p> <span class="material-symbols-rounded">arrow_back</span>
</button>
<div>
<h1 style="margin: 0; font-size: 24px;">Student Users</h1>
<p style="margin: 4px 0 0;" class="page-subtitle">View and manage registered students</p>
</div>
</div> </div>
<div class="filter-tabs"> <div class="filter-tabs">
......
import { Component, OnInit, signal } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { Location } from '@angular/common';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
...@@ -16,7 +17,11 @@ export class AdminUsersComponent implements OnInit { ...@@ -16,7 +17,11 @@ export class AdminUsersComponent implements OnInit {
loading = signal<boolean>(true); loading = signal<boolean>(true);
showAll = signal<boolean>(true); showAll = signal<boolean>(true);
constructor(public authService: AuthService, private quizService: QuizService) {} constructor(public authService: AuthService, private quizService: QuizService, private location: Location) {}
goBack(): void {
this.location.back();
}
ngOnInit(): void { ngOnInit(): void {
this.loadUsers(); this.loadUsers();
......
...@@ -12,18 +12,41 @@ import { QuizService } from '../../../services/quiz.service'; ...@@ -12,18 +12,41 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './dashboard.css' styleUrl: './dashboard.css'
}) })
export class CandidateDashboardComponent implements OnInit { export class CandidateDashboardComponent implements OnInit {
quizzes = signal<any[]>([]); interviews = signal<any[]>([]);
loading = signal(true); loading = signal(true);
uploadingCodingId = signal<string | null>(null);
constructor(public authService: AuthService, private quizService: QuizService) {} constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void { ngOnInit(): void {
this.quizService.getAvailableQuizzes().subscribe({ this.loadInterviews();
}
loadInterviews(): void {
this.quizService.getCandidateInterviews().subscribe({
next: (res) => { next: (res) => {
this.quizzes.set(res.quizzes); this.interviews.set(res.interviews);
this.loading.set(false); this.loading.set(false);
}, },
error: () => this.loading.set(false) error: () => this.loading.set(false)
}); });
} }
onCodingZipSelected(event: any, interviewId: string): void {
const file = event.target.files[0];
if (file) {
this.uploadingCodingId.set(interviewId);
this.quizService.uploadCandidateCodingSubmission(interviewId, file).subscribe({
next: (res) => {
this.uploadingCodingId.set(null);
this.loadInterviews();
alert('Coding round submitted successfully!');
},
error: (err) => {
this.uploadingCodingId.set(null);
alert(err.error?.message || 'Failed to submit coding round');
}
});
}
}
} }
...@@ -110,22 +110,24 @@ ...@@ -110,22 +110,24 @@
/* Quick Actions */ /* Quick Actions */
.actions-grid { .actions-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(4,1fr);
gap: 16px; gap: 16px;
} }
.action-card { .action-card {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 16px; gap: 16px;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
height: 100%;
} }
.action-icon { .action-icon {
font-size: 28px; font-size: 28px;
color: var(--accent-primary); color: var(--accent-primary);
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
} }
.action-info { .action-info {
...@@ -143,12 +145,19 @@ ...@@ -143,12 +145,19 @@
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 38px;
} }
.action-arrow { .action-arrow {
color: var(--text-muted); color: var(--text-muted);
font-size: 20px; font-size: 20px;
transition: transform 0.2s; transition: transform 0.2s;
margin-top: 5px;
} }
.action-card:hover .action-arrow { .action-card:hover .action-arrow {
......
...@@ -61,6 +61,14 @@ ...@@ -61,6 +61,14 @@
<div class="section"> <div class="section">
<h2 class="section-title">Quick Actions</h2> <h2 class="section-title">Quick Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<a (click)="openInterviewPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">groups</span>
<div class="action-info">
<h3>Interviews</h3>
<p>Manage group and individual interview evaluations</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;"> <a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span> <span class="material-symbols-rounded action-icon">group</span>
<div class="action-info"> <div class="action-info">
......
...@@ -32,4 +32,8 @@ export class HRDashboardComponent implements OnInit { ...@@ -32,4 +32,8 @@ export class HRDashboardComponent implements OnInit {
openUsersPopup(): void { openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true); this.uiService.showManageUsersPopup.set(true);
} }
openInterviewPopup(): void {
this.uiService.showInterviewPopup.set(true);
}
} }
import { Component } from '@angular/core';
@Component({
selector: 'app-group-interview',
imports: [],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css',
})
export class GroupInterviewComponent {}
import { Component } from '@angular/core';
@Component({
selector: 'app-individual-interview',
imports: [],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css',
})
export class IndividualInterview {}
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1000px; margin: 0 auto; }
/* Header */
.profile-header {
display: flex; align-items: flex-start; gap: 24px; padding: 32px;
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 20px; margin-bottom: 24px; box-shadow: 0 4px 24px rgba(0,0,0,0.02);
position: relative; overflow: hidden;
}
.profile-header::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 120px;
background: linear-gradient(135deg, rgba(102,126,234,0.1), rgba(118,75,162,0.1));
z-index: 0; pointer-events: none;
}
.ph-avatar {
width: 96px; height: 96px; border-radius: 24px; background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 36px; font-weight: 700; flex-shrink: 0;
box-shadow: 0 8px 24px rgba(102,126,234,0.3); z-index: 1; border: 4px solid var(--bg-card);
}
.ph-info { flex: 1; z-index: 1; margin-top: 12px; }
.ph-info h1 { font-size: 28px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.ph-role { font-size: 13px; font-weight: 600; color: #667eea; text-transform: uppercase; letter-spacing: 1px; margin: 0 0 16px; }
.ph-meta { display: flex; flex-wrap: wrap; gap: 16px; }
.meta-item { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.ph-actions { z-index: 1; margin-top: 12px; }
/* Grid Layout */
.profile-grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
/* Cards */
.content-card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 16px; overflow: hidden;
}
.card-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.card-header h2 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.card-body { padding: 24px; }
/* Signature specific */
.signature-box {
padding: 20px; border: 2px dashed var(--border-color); border-radius: 12px;
background: var(--bg-input); text-align: center;
max-width: 400px; margin: 0 auto;
}
.signature-box img {
max-width: 100%; max-height: 120px; object-fit: contain;
}
/* Modals */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); backdrop-filter: blur(4px);
z-index: 1000; display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: var(--bg-card); border-radius: 16px; box-shadow: 0 10px 50px rgba(0,0,0,0.25);
border: 1px solid var(--border-color); width: 90%; max-width: 500px;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.modal-header h2 { font-size: 18px; font-weight: 600; margin: 0; color: var(--text-primary); }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-hover);
border-radius: 0 0 16px 16px;
}
/* Forms */
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
/* Utilities */
.btn-icon {
width: 32px; height: 32px; border-radius: 8px; border: none; background: transparent;
color: var(--text-muted); display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s;
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
.text-muted { color: var(--text-muted); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 60px; color: var(--text-muted); gap: 16px; }
.empty-state { text-align: center; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 48px; opacity: 0.5; margin-bottom: 16px; display: block; }
.empty-state h3 { font-size: 16px; color: var(--text-primary); margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (max-width: 768px) {
.page-container { padding: 20px; }
.profile-header { flex-direction: column; align-items: center; text-align: center; padding: 24px; }
.ph-meta { justify-content: center; }
}
<div class="page-container">
<div class="content-wrapper">
@if (loading()) {
<div class="loading-center">
<span class="spinner"></span>
<p>Loading your profile...</p>
</div>
} @else if (user()) {
<div class="profile-header">
<div class="ph-avatar">
{{ user().name?.charAt(0)?.toUpperCase() }}
</div>
<div class="ph-info">
<h1>{{ user().name }}</h1>
<p class="ph-role">{{ user().role | uppercase }}</p>
<div class="ph-meta">
<span class="meta-item"><span class="material-symbols-rounded">mail</span> {{ user().email }}</span>
@if (user().phoneNumber) {
<span class="meta-item"><span class="material-symbols-rounded">call</span> {{ user().phoneNumber }}</span>
}
</div>
</div>
<div class="ph-actions">
<button class="btn btn-outline" (click)="openEditProfile()">
<span class="material-symbols-rounded">edit</span> Edit Profile
</button>
</div>
</div>
<div class="profile-grid">
<div class="pg-main">
<!-- Signature Section -->
<div class="content-card">
<div class="card-header">
<h2>Digital Signature</h2>
</div>
<div class="card-body">
@if (user().signature) {
<div class="signature-box">
<img [src]="getSignatureUrl()" alt="Your Signature">
</div>
<div class="action-row" style="margin-top: 16px;">
<label class="btn btn-primary" style="cursor: pointer;">
@if (isUploadingSignature()) { <span class="spinner spinner-sm"></span> Uploading... }
@else { <span class="material-symbols-rounded">upload</span> Replace Signature }
<input type="file" accept="image/*" style="display: none;" (change)="uploadSignature($event)">
</label>
</div>
} @else {
<div class="empty-state" style="padding: 40px 20px;">
<span class="material-symbols-rounded">draw</span>
<h3>No Signature Uploaded</h3>
<p>Upload your digital signature to attach it to evaluation forms automatically.</p>
<label class="btn btn-primary" style="margin-top: 16px; cursor: pointer; display: inline-flex;">
@if (isUploadingSignature()) { <span class="spinner spinner-sm"></span> Uploading... }
@else { <span class="material-symbols-rounded">upload</span> Upload Signature }
<input type="file" accept="image/*" style="display: none;" (change)="uploadSignature($event)">
</label>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>
<!-- Edit Profile Modal -->
@if (isEditingProfile()) {
<div class="modal-overlay" (click)="closeEditProfile()">
<div class="modal-container" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Edit Profile</h2>
<button class="btn-icon" (click)="closeEditProfile()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Full Name *</label>
<input class="form-input" [(ngModel)]="editName" placeholder="Enter your full name">
</div>
<div class="form-group">
<label class="form-label">Email *</label>
<input class="form-input" [(ngModel)]="editEmail" placeholder="Enter your email" type="email">
</div>
<div class="form-group">
<label class="form-label">Phone Number</label>
<input class="form-input" [(ngModel)]="editPhone" placeholder="Enter phone number">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeEditProfile()">Cancel</button>
<button class="btn btn-primary" (click)="saveProfile()" [disabled]="isSavingProfile()">
@if (isSavingProfile()) { <span class="spinner spinner-sm"></span> Saving... }
@else { Save Changes }
</button>
</div>
</div>
</div>
}
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-staff-profile',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './profile.html',
styleUrl: './profile.css'
})
export class StaffProfileComponent implements OnInit {
user = signal<any>(null);
loading = signal(true);
// Edit Profile State
isEditingProfile = signal(false);
isSavingProfile = signal(false);
editName = signal('');
editEmail = signal('');
editPhone = signal('');
isUploadingSignature = signal(false);
constructor(public authService: AuthService) {}
ngOnInit(): void {
this.authService.getMe().subscribe({
next: (res) => {
this.user.set(res.user);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
openEditProfile() {
const currentUser = this.user();
if (currentUser) {
this.editName.set(currentUser.name || '');
this.editEmail.set(currentUser.email || '');
this.editPhone.set(currentUser.phoneNumber || '');
this.isEditingProfile.set(true);
}
}
closeEditProfile() {
this.isEditingProfile.set(false);
}
saveProfile() {
if (!this.editName().trim() || !this.editEmail().trim()) {
alert("Name and Email are required");
return;
}
this.isSavingProfile.set(true);
const formData = new FormData();
formData.append('name', this.editName());
formData.append('email', this.editEmail());
formData.append('phoneNumber', this.editPhone());
this.authService.updateProfile(formData).subscribe({
next: (res) => {
this.user.set(res.user);
// Also update authService currentUser partially
const current = this.authService.currentUser();
if (current) {
this.authService.currentUser.set({...current, name: res.user.name, email: res.user.email});
}
this.isSavingProfile.set(false);
this.closeEditProfile();
},
error: (err) => {
console.error(err);
alert('Failed to update profile');
this.isSavingProfile.set(false);
}
});
}
uploadSignature(event: any): void {
const file = event.target.files[0];
if (!file) return;
this.isUploadingSignature.set(true);
const formData = new FormData();
formData.append('signature', file);
this.authService.uploadSignature(formData).subscribe({
next: (res) => {
this.isUploadingSignature.set(false);
if (res.signature) {
// Update user signal
this.user.update(u => ({ ...u, signature: res.signature }));
}
},
error: (err) => {
this.isUploadingSignature.set(false);
alert(err.error?.message || 'Error uploading signature');
}
});
}
deleteSignature(): void {
if (!confirm('Are you sure you want to delete your signature?')) return;
// We could make a DELETE endpoint, but for now we just upload an empty file or assume there is an endpoint.
// Actually, backend auth.js has no delete signature endpoint. Let's just alert that it's not supported yet
// or we can just not provide delete for now, or add an endpoint.
alert('Deleting signature is not implemented yet. Please upload a new one to replace it.');
}
getSignatureUrl(): string {
const signature = this.user()?.signature;
if (!signature) return '';
return `http://localhost:5000${signature}`;
}
}
...@@ -7,7 +7,7 @@ export interface User { ...@@ -7,7 +7,7 @@ export interface User {
id: string; id: string;
name: string; name: string;
email: string; email: string;
role: 'admin' | 'hr' | 'candidate'; role: 'admin' | 'hr' | 'candidate' | 'pm' | 'interviewer';
group?: string; group?: string;
} }
...@@ -52,6 +52,18 @@ export class AuthService { ...@@ -52,6 +52,18 @@ export class AuthService {
.pipe(tap(res => this.handleAuth(res))); .pipe(tap(res => this.handleAuth(res)));
} }
getMe(): Observable<any> {
return this.http.get(`${this.apiUrl}/me`);
}
uploadSignature(formData: FormData): Observable<any> {
return this.http.post(`${this.apiUrl}/signature`, formData);
}
updateProfile(formData: FormData): Observable<any> {
return this.http.put(`${this.apiUrl}/profile`, formData);
}
logout(): void { logout(): void {
const token = sessionStorage.getItem('token'); const token = sessionStorage.getItem('token');
if (token) { if (token) {
...@@ -95,6 +107,8 @@ export class AuthService { ...@@ -95,6 +107,8 @@ export class AuthService {
case 'admin': return '/admin/dashboard'; case 'admin': return '/admin/dashboard';
case 'hr': return '/hr/dashboard'; case 'hr': return '/hr/dashboard';
case 'candidate': return '/candidate/dashboard'; case 'candidate': return '/candidate/dashboard';
case 'pm': return '/pm/dashboard';
case 'interviewer': return '/interviewer/dashboard';
default: return '/login'; default: return '/login';
} }
} }
......
...@@ -27,8 +27,8 @@ ...@@ -27,8 +27,8 @@
return this.http.get(`${this.getBaseUrl()}/users/logged-in`); return this.http.get(`${this.getBaseUrl()}/users/logged-in`);
} }
createHRUser(data: { name: string; email: string; password: string }): Observable<any> { createStaffUser(data: { name: string; email: string; password: string; role: string }): Observable<any> {
return this.http.post(`${this.adminUrl}/users/create-hr`, data); // Only admin can create HR return this.http.post(`${this.adminUrl}/users/create-staff`, data); // Only admin can create staff
} }
deleteUser(userId: string): Observable<any> { deleteUser(userId: string): Observable<any> {
...@@ -40,13 +40,13 @@ ...@@ -40,13 +40,13 @@
} }
getUserHistory(userId: string): Observable<any> { getUserHistory(userId: string): Observable<any> {
// In HR route it is /candidates/:userId/history but let's check backend hr.js
// Wait, hr.js has /candidates/:userId/history and /candidates instead of /users for HR?
// No, hr.js has /users, /users/logged-in, /users/:userId, etc.
// Wait, let's verify.
return this.http.get(`${this.getBaseUrl()}/users/${userId}/history`); return this.http.get(`${this.getBaseUrl()}/users/${userId}/history`);
} }
getUserInterviews(userId: string): Observable<any> {
return this.http.get(`${this.adminUrl}/users/${userId}/interviews`);
}
updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> { updateUserLevel(userId: string, level: string, role: string = 'admin'): Observable<any> {
return this.http.put(`${this.getBaseUrl()}/users/${userId}/level`, { level }); return this.http.put(`${this.getBaseUrl()}/users/${userId}/level`, { level });
} }
...@@ -167,6 +167,10 @@ ...@@ -167,6 +167,10 @@
return this.http.get(`${this.candidateUrl}/quizzes`); return this.http.get(`${this.candidateUrl}/quizzes`);
} }
getCandidateInterviews(): Observable<any> {
return this.http.get(`${this.candidateUrl}/interviews`);
}
getQuizForTaking(quizId: string): Observable<any> { getQuizForTaking(quizId: string): Observable<any> {
return this.http.get(`${this.candidateUrl}/quiz/${quizId}`); return this.http.get(`${this.candidateUrl}/quiz/${quizId}`);
} }
...@@ -191,6 +195,12 @@ ...@@ -191,6 +195,12 @@
return this.http.get(`${this.candidateUrl}/results/${submissionId}`); return this.http.get(`${this.candidateUrl}/results/${submissionId}`);
} }
uploadCandidateCodingSubmission(interviewId: string, file: File): Observable<any> {
const formData = new FormData();
formData.append('codingZip', file);
return this.http.post(`${this.candidateUrl}/interview/${interviewId}/coding`, formData);
}
// ========== GENERIC GROUP MANAGEMENT ========== // ========== GENERIC GROUP MANAGEMENT ==========
private getBaseUrl(): string { private getBaseUrl(): string {
...@@ -223,4 +233,62 @@ ...@@ -223,4 +233,62 @@
assignUserGroup(userId: string, groupName: string): Observable<any> { assignUserGroup(userId: string, groupName: string): Observable<any> {
return this.http.put(`${this.getBaseUrl()}/users/${userId}/group`, { group: groupName }); return this.http.put(`${this.getBaseUrl()}/users/${userId}/group`, { group: groupName });
} }
// ========== INTERVIEW ENDPOINTS ==========
private interviewUrl = 'http://localhost:5000/api/interview';
getInterviews(params?: any): Observable<any> {
let url = this.interviewUrl;
if (params) {
const query = Object.entries(params).filter(([,v]) => v).map(([k,v]) => `${k}=${v}`).join('&');
if (query) url += `?${query}`;
}
return this.http.get(url);
}
getInterviewStats(): Observable<any> {
return this.http.get(`${this.interviewUrl}/stats`);
}
getInterviewById(id: string): Observable<any> {
return this.http.get(`${this.interviewUrl}/${id}`);
}
createInterview(data: any): Observable<any> {
return this.http.post(this.interviewUrl, data);
}
updateInterview(id: string, data: any): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}`, data);
}
submitEvaluation(id: string, data: any): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}/evaluate`, data);
}
setInterviewDecision(id: string, decision: string): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}/decision`, { decision });
}
uploadCodingSubmission(id: string, file: File): Observable<any> {
const formData = new FormData();
formData.append('codingZip', file);
return this.http.post(`${this.interviewUrl}/${id}/coding-submission`, formData);
}
validateCodingRound(id: string): Observable<any> {
return this.http.put(`${this.interviewUrl}/${id}/validate-coding`, {});
}
deleteInterview(id: string): Observable<any> {
return this.http.delete(`${this.interviewUrl}/${id}`);
}
getInterviewers(): Observable<any> {
return this.http.get(`${this.interviewUrl}/interviewers`);
}
getInterviewCandidates(): Observable<any> {
return this.http.get(`${this.interviewUrl}/candidates`);
}
} }
...@@ -5,4 +5,5 @@ import { Injectable, signal } from '@angular/core'; ...@@ -5,4 +5,5 @@ import { Injectable, signal } from '@angular/core';
}) })
export class UiService { export class UiService {
showManageUsersPopup = signal<boolean>(false); showManageUsersPopup = signal<boolean>(false);
showInterviewPopup = signal<boolean>(false);
} }
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment