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
......
This diff is collapsed.
......@@ -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 {
......
......@@ -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';
}
}
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;
......
......@@ -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();
......
......@@ -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