Commit 9caafb31 authored by Aravind RK's avatar Aravind RK

fix : minor bugs in portal of all respective roles regarding hroup interview

parent df8a1773
...@@ -86,8 +86,8 @@ router.get('/', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) ...@@ -86,8 +86,8 @@ router.get('/', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res)
const { status, type } = req.query; const { status, type } = req.query;
let filter = {}; let filter = {};
// Evaluators only see their assigned interviews or created by them (unless Admin) // Evaluators only see their assigned interviews or created by them (unless Admin/HR)
if (req.user.role !== 'admin') { if (!['admin', 'hr'].includes(req.user.role)) {
filter.$or = [ filter.$or = [
{ interviewerId: req.user._id }, { interviewerId: req.user._id },
{ assignedInterviewers: req.user._id }, { assignedInterviewers: req.user._id },
...@@ -124,7 +124,7 @@ router.get('/', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) ...@@ -124,7 +124,7 @@ router.get('/', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res)
router.get('/stats', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => { router.get('/stats', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, res) => {
try { try {
let filter = {}; let filter = {};
if (req.user.role !== 'admin') { if (!['admin', 'hr'].includes(req.user.role)) {
filter.$or = [ filter.$or = [
{ interviewerId: req.user._id }, { interviewerId: req.user._id },
{ assignedInterviewers: req.user._id }, { assignedInterviewers: req.user._id },
...@@ -314,13 +314,17 @@ router.get('/:id', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, re ...@@ -314,13 +314,17 @@ router.get('/:id', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, re
} }
// Check authorization to view // Check authorization to view
if (req.user.role !== 'admin') { // Note: after populate(), assigned arrays contain objects — extract ._id for comparison
if (!['admin', 'hr'].includes(req.user.role)) {
const userId = req.user._id.toString();
const getId = (u) => (u && typeof u === 'object' ? (u._id || u).toString() : u.toString());
const isAuthorized = const isAuthorized =
(interview.interviewerId && interview.interviewerId._id.toString() === req.user._id.toString()) || (interview.interviewerId && getId(interview.interviewerId) === userId) ||
interview.assignedInterviewers?.some(u => u.toString() === req.user._id.toString()) || interview.assignedInterviewers?.some(u => getId(u) === userId) ||
interview.assignedHRs?.some(u => u.toString() === req.user._id.toString()) || interview.assignedHRs?.some(u => getId(u) === userId) ||
interview.assignedPMs?.some(u => u.toString() === req.user._id.toString()) || interview.assignedPMs?.some(u => getId(u) === userId) ||
(interview.createdBy && interview.createdBy._id.toString() === req.user._id.toString()); (interview.createdBy && getId(interview.createdBy) === userId);
if (!isAuthorized) { if (!isAuthorized) {
return res.status(403).json({ message: 'Not authorized to view this interview' }); return res.status(403).json({ message: 'Not authorized to view this interview' });
......
...@@ -125,7 +125,11 @@ export const routes: Routes = [ ...@@ -125,7 +125,11 @@ export const routes: Routes = [
}, },
{ {
path: 'individual-interview', path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent) loadComponent: () => import('./pages/hr/individual-interview/individual-interview').then(m => m.HRIndividualInterviewComponent)
},
{
path: 'group-interview',
loadComponent: () => import('./pages/hr/group-interview/group-interview').then(m => m.HRGroupInterviewComponent)
}, },
{ {
path: 'profile', path: 'profile',
...@@ -143,11 +147,15 @@ export const routes: Routes = [ ...@@ -143,11 +147,15 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ {
path: 'dashboard', path: 'dashboard',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent) loadComponent: () => import('./pages/ProjectManager/dashboard/dashboard').then(m => m.DashboardComponent)
}, },
{ {
path: 'interviews', path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent) loadComponent: () => import('./pages/ProjectManager/individual-interview/individual-interview').then(m => m.PMIndividualInterviewComponent)
},
{
path: 'group-interview',
loadComponent: () => import('./pages/ProjectManager/group-interview/group-interview').then(m => m.PMGroupInterviewComponent)
}, },
{ {
path: 'profile', path: 'profile',
...@@ -165,11 +173,15 @@ export const routes: Routes = [ ...@@ -165,11 +173,15 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ {
path: 'dashboard', path: 'dashboard',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent) loadComponent: () => import('./pages/Interviewer/dashboard/dashboard').then(m => m.DashboardComponent)
}, },
{ {
path: 'interviews', path: 'individual-interview',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent) loadComponent: () => import('./pages/Interviewer/individual-interview/individual-interview').then(m => m.InterviewerIndividualInterviewComponent)
},
{
path: 'group-interview',
loadComponent: () => import('./pages/Interviewer/group-interview/group-interview').then(m => m.InterviewerGroupInterviewComponent)
}, },
{ {
path: 'profile', path: 'profile',
......
...@@ -180,7 +180,7 @@ ...@@ -180,7 +180,7 @@
<a routerLink="/admin/individual-interview" class="glassy-option" <a [routerLink]="getIndividualInterviewRoute()" class="glassy-option"
(click)="uiService.showInterviewPopup.set(false)"> (click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper"> <div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon"> <span class="material-symbols-rounded block-icon">
...@@ -198,7 +198,7 @@ ...@@ -198,7 +198,7 @@
</span> </span>
</a> </a>
<a routerLink="/admin/group-interview" class="glassy-option" (click)="uiService.showInterviewPopup.set(false)"> <a [routerLink]="getGroupInterviewRoute()" class="glassy-option" (click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper"> <div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon"> <span class="material-symbols-rounded block-icon">
groups groups
......
...@@ -56,13 +56,15 @@ export class LayoutComponent { ...@@ -56,13 +56,15 @@ export class LayoutComponent {
case 'pm': case 'pm':
return [ return [
{ icon: 'dashboard', label: 'Dashboard', route: '/pm/dashboard' }, { icon: 'dashboard', label: 'Dashboard', route: '/pm/dashboard' },
{ icon: 'work', label: 'Interviews', route: '/pm/interviews' }, { icon: 'person_search', label: 'Individual', route: '/pm/individual-interview' },
{ icon: 'groups', label: 'Group', route: '/pm/group-interview' },
{ icon: 'person', label: 'Profile', route: '/pm/profile' }, { icon: 'person', label: 'Profile', route: '/pm/profile' },
]; ];
case 'interviewer': case 'interviewer':
return [ return [
{ icon: 'dashboard', label: 'Dashboard', route: '/interviewer/dashboard' }, { icon: 'dashboard', label: 'Dashboard', route: '/interviewer/dashboard' },
{ icon: 'assignment_ind', label: 'My Interviews', route: '/interviewer/interviews' }, { icon: 'person_search', label: 'Individual', route: '/interviewer/individual-interview' },
{ icon: 'groups', label: 'Group', route: '/interviewer/group-interview' },
{ icon: 'person', label: 'Profile', route: '/interviewer/profile' }, { icon: 'person', label: 'Profile', route: '/interviewer/profile' },
]; ];
case 'candidate': case 'candidate':
...@@ -128,4 +130,20 @@ export class LayoutComponent { ...@@ -128,4 +130,20 @@ export class LayoutComponent {
getManageGroupsRoute(): string { getManageGroupsRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/manage-groups' : '/admin/manage-groups'; return this.authService.getUserRole() === 'hr' ? '/hr/manage-groups' : '/admin/manage-groups';
} }
getIndividualInterviewRoute(): string {
const role = this.authService.getUserRole();
if (role === 'hr') return '/hr/individual-interview';
if (role === 'pm') return '/pm/individual-interview';
if (role === 'interviewer') return '/interviewer/individual-interview';
return '/admin/individual-interview';
}
getGroupInterviewRoute(): string {
const role = this.authService.getUserRole();
if (role === 'hr') return '/hr/group-interview';
if (role === 'pm') return '/pm/group-interview';
if (role === 'interviewer') return '/interviewer/group-interview';
return '/admin/group-interview';
}
} }
/* Admin Dashboard */
.page {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
/* Loading */
.loading-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
gap: 16px;
}
.loading-text {
font-size: 14px;
color: var(--text-muted);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-wrap .material-symbols-rounded {
font-size: 24px;
color: #fff;
}
.stat-icon-wrap.blue { background: linear-gradient(135deg, #4f6ef7, #3b5bdb); }
.stat-icon-wrap.purple { background: linear-gradient(135deg, #7c5cfc, #6033e0); }
.stat-icon-wrap.green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.stat-icon-wrap.orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon-wrap.teal { background: linear-gradient(135deg, #14b8a6, #0d9488); }
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
/* Sections */
.section {
margin-bottom: 32px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.action-card {
display: flex;
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 {
flex: 1;
}
.action-info h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.action-info p {
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 {
transform: translateX(4px);
color: var(--accent-primary);
}
/* Table helpers */
.user-cell {
display: flex;
flex-direction: column;
}
.user-cell-name {
font-weight: 600;
font-size: 14px;
}
.user-cell-email {
font-size: 12px;
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.page { padding: 20px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.actions-grid { grid-template-columns: 1fr; }
}
<div class="page animate-fade-in">
<div class="page-header">
<div>
<h1>Interviewer Dashboard</h1>
<p class="page-subtitle">Overview of your assigned candidate interviews</p>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else {
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">work</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().total }}</span>
<span class="stat-label">Total Assigned</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">pending_actions</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().pending }}</span>
<span class="stat-label">Pending</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">check_circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().completed }}</span>
<span class="stat-label">Evaluated</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section" style="margin-top: 32px;">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a routerLink="/interviewer/individual-interview" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">person_search</span>
<div class="action-info">
<h3>Individual Interviews</h3>
<p>Evaluate individual candidates</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/interviewer/group-interview" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">groups</span>
<div class="action-info">
<h3>Group Interviews</h3>
<p>Evaluate group candidate pools</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
</div>
}
</div>
\ No newline at end of file
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-interviewer-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class DashboardComponent implements OnInit {
stats = signal<any>({ total: 0, pending: 0, completed: 0 });
loading = signal(true);
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getInterviewStats().subscribe({
next: (res) => {
this.stats.set(res);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
}
import { Component, OnInit, signal, computed } 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-interviewer-group-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css'
})
export class InterviewerGroupInterviewComponent implements OnInit {
// -- List state --------------------------------------------
interviews = signal<any[]>([]);
loading = signal(true);
filterStatus = '';
// -- Grouped view (one card per groupId) ------------------
groupedList = computed<any[]>(() => {
const map = new Map<string, any>();
for (const iv of this.interviews()) {
const key = iv.groupId || 'ungrouped';
if (!map.has(key)) {
map.set(key, {
groupId: key,
position: iv.position,
techStack: iv.techStack,
source: iv.source,
dateOfInterview: iv.dateOfInterview,
assignedInterviewers: iv.assignedInterviewers,
assignedHRs: iv.assignedHRs,
assignedPMs: iv.assignedPMs,
members: []
});
}
map.get(key)!.members.push(iv);
}
return Array.from(map.values());
});
stats = signal<any>({ total: 0, pending: 0, completed: 0, accepted: 0, rejected: 0 });
// -- Group detail modal state -----------------------------
showDetailModal = signal(false);
selectedGroup = signal<any>(null);
// -- Per-candidate (member) detail modal ------------------
showMemberDetailModal = signal(false);
selectedMember = signal<any>(null);
memberEvalComment = '';
memberEvalRecommendation = '';
isMemberSubmitting = signal(false);
isPdfGenerating = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
ngOnInit(): void {
this.loadInterviews();
this.loadStats();
}
loadInterviews(): void {
this.loading.set(true);
const params: any = { type: 'group' };
if (this.filterStatus) params.status = this.filterStatus;
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) });
}
onFilterChange(): void { this.loadInterviews(); }
// -- Group detail modal ------------------------------------
openDetail(group: any): void {
this.selectedGroup.set(group);
this.showDetailModal.set(true);
}
closeDetail(): void {
this.showDetailModal.set(false);
this.selectedGroup.set(null);
}
// -- Member (candidate) detail modal ----------------------
openMemberDetail(interviewId: string, event: Event): void {
event.stopPropagation();
this.quizService.getInterviewById(interviewId).subscribe({
next: res => {
this.selectedMember.set(res.interview);
this.memberEvalComment = '';
this.memberEvalRecommendation = '';
this.showMemberDetailModal.set(true);
}
});
}
closeMemberDetail(): void {
this.showMemberDetailModal.set(false);
this.selectedMember.set(null);
}
submitMemberEvaluation(): void {
const m = this.selectedMember();
if (!m || !this.memberEvalRecommendation) return;
this.isMemberSubmitting.set(true);
this.quizService.submitEvaluation(m._id, {
comments: this.memberEvalComment,
recommendation: this.memberEvalRecommendation
}).subscribe({
next: res => {
this.selectedMember.set(res.interview);
this.memberEvalComment = '';
this.memberEvalRecommendation = '';
this.isMemberSubmitting.set(false);
this.loadInterviews();
},
error: () => this.isMemberSubmitting.set(false)
});
}
hasMemberEvaluated(): boolean {
const m = this.selectedMember();
if (!m) return false;
const userId = this.authService.currentUser()?.id;
return m.evaluations?.some((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId);
}
allMemberEvaluationsDone(): boolean {
const m = this.selectedMember();
if (!m) return false;
const total = (m.assignedInterviewers?.length || 0) +
(m.assignedHRs?.length || 0) +
(m.assignedPMs?.length || 0);
if (total === 0) return false;
return (m.evaluations?.length || 0) >= total;
}
needsEvaluation(m: any): boolean {
if (m.status === 'completed') return false;
const userId = this.authService.currentUser()?.id;
const evaluated = m.evaluations?.some(
(e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId
);
return !evaluated;
}
hasPendingEvaluations(m: any): boolean {
if (m.status === 'completed') return false;
const total = (m.assignedInterviewers?.length || 0) +
(m.assignedHRs?.length || 0) +
(m.assignedPMs?.length || 0);
return (m.evaluations?.length || 0) < total;
}
downloadEvaluationPdf(): void {
const m = this.selectedMember();
if (!m) return;
this.isPdfGenerating.set(true);
setTimeout(() => {
window.print();
this.isPdfGenerating.set(false);
}, 500);
}
// -- Helpers -----------------------------------------------
getStatusClass(status: string): string {
const map: any = {
pending: 'badge-warning', quiz_phase: 'badge-info',
coding_phase: 'badge-info', evaluation: 'badge-purple', completed: 'badge-success'
};
return map[status] || '';
}
getDecisionClass(decision: string): string {
const map: any = {
accepted: 'badge-success', rejected: 'badge-danger',
on_hold: 'badge-warning', '2nd_round': 'badge-info'
};
return map[decision] || 'badge-muted';
}
formatStatus(status: string): string {
return (status || '').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
formatDecision(d: string): string {
const m: any = { pending: 'Pending', accepted: 'Accepted', rejected: 'Rejected', on_hold: 'On Hold', '2nd_round': '2nd Round' };
return m[d] || d;
}
groupStatusSummary(members: any[]): string {
const counts: any = {};
for (const m of members) counts[m.status] = (counts[m.status] || 0) + 1;
return Object.entries(counts).map(([s, c]) => this.formatStatus(s) + ': ' + c).join(' · ');
}
pendingCount(members: any[]): number { return members.filter(m => m.status !== 'completed').length; }
completedCount(members: any[]): number { return members.filter(m => m.status === 'completed').length; }
// -- Quick-evaluate dropdown ---------------------------
expandedGroups = new Set<string>();
toggleExpand(groupId: string, event: Event): void {
event.stopPropagation();
if (this.expandedGroups.has(groupId)) {
this.expandedGroups.delete(groupId);
} else {
this.expandedGroups.add(groupId);
}
}
isExpanded(groupId: string): boolean {
return this.expandedGroups.has(groupId);
}
groupHasPendingEvals(members: any[]): boolean {
const userId = this.authService.currentUser()?.id;
return members.some(m =>
m.status !== 'completed' &&
!m.evaluations?.some((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId)
);
}
getVisibleEvaluations(evaluations: any[]): any[] {
if (!evaluations) return [];
const userId = this.authService.currentUser()?.id;
return evaluations.filter((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId);
}
}
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-interviewer-individual-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css'
})
export class InterviewerIndividualInterviewComponent implements OnInit {
interviews = signal<any[]>([]);
loading = signal(true);
showDetailModal = signal(false);
selectedInterview = signal<any>(null);
evalComment = signal('');
evalRecommendation = signal('');
stats = signal<any>({ total: 0, pending: 0, completed: 0, accepted: 0, rejected: 0 });
filterStatus = signal('');
isSubmitting = signal(false);
isPdfGenerating = 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)
});
}
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)
});
}
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);
}
getEvaluationByRole(role: string): any {
const iv = this.selectedInterview();
if (!iv || !iv.evaluations) return null;
return iv.evaluations.find((e: any) => e.evaluatorRole === role) || null;
}
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);
}
getVisibleEvaluations(evaluations: any[]): any[] {
if (!evaluations) return [];
const userId = this.authService.currentUser()?.id;
return evaluations.filter((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId);
}
}
/* Admin Dashboard */
.page {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
/* Loading */
.loading-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
gap: 16px;
}
.loading-text {
font-size: 14px;
color: var(--text-muted);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-wrap .material-symbols-rounded {
font-size: 24px;
color: #fff;
}
.stat-icon-wrap.blue { background: linear-gradient(135deg, #4f6ef7, #3b5bdb); }
.stat-icon-wrap.purple { background: linear-gradient(135deg, #7c5cfc, #6033e0); }
.stat-icon-wrap.green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.stat-icon-wrap.orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon-wrap.teal { background: linear-gradient(135deg, #14b8a6, #0d9488); }
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
/* Sections */
.section {
margin-bottom: 32px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(4,1fr);
gap: 16px;
}
.action-card {
display: flex;
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 {
flex: 1;
}
.action-info h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.action-info p {
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 {
transform: translateX(4px);
color: var(--accent-primary);
}
/* Table helpers */
.user-cell {
display: flex;
flex-direction: column;
}
.user-cell-name {
font-weight: 600;
font-size: 14px;
}
.user-cell-email {
font-size: 12px;
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.page { padding: 20px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.actions-grid { grid-template-columns: 1fr; }
}
<div class="page animate-fade-in">
<div class="page-header">
<div>
<h1>Project Manager Dashboard</h1>
<p class="page-subtitle">Overview of your assigned candidate interviews</p>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else {
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">work</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().total }}</span>
<span class="stat-label">Total Assigned</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">pending_actions</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().pending }}</span>
<span class="stat-label">Pending</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">check_circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().completed }}</span>
<span class="stat-label">Evaluated</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section" style="margin-top: 32px;">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a routerLink="/pm/individual-interview" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">person_search</span>
<div class="action-info">
<h3>Individual Interviews</h3>
<p>Evaluate individual candidates</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/pm/group-interview" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">groups</span>
<div class="action-info">
<h3>Group Interviews</h3>
<p>Evaluate group candidate pools</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
</div>
}
</div>
\ No newline at end of file
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-pm-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class DashboardComponent implements OnInit {
stats = signal<any>({ total: 0, pending: 0, completed: 0 });
loading = signal(true);
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getInterviewStats().subscribe({
next: (res) => {
this.stats.set(res);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
}
import { Component, OnInit, signal, computed } 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-pm-group-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css'
})
export class PMGroupInterviewComponent implements OnInit {
// -- List state --------------------------------------------
interviews = signal<any[]>([]);
loading = signal(true);
filterStatus = '';
// -- Grouped view (one card per groupId) ------------------
groupedList = computed<any[]>(() => {
const map = new Map<string, any>();
for (const iv of this.interviews()) {
const key = iv.groupId || 'ungrouped';
if (!map.has(key)) {
map.set(key, {
groupId: key,
position: iv.position,
techStack: iv.techStack,
source: iv.source,
dateOfInterview: iv.dateOfInterview,
assignedInterviewers: iv.assignedInterviewers,
assignedHRs: iv.assignedHRs,
assignedPMs: iv.assignedPMs,
members: []
});
}
map.get(key)!.members.push(iv);
}
return Array.from(map.values());
});
stats = signal<any>({ total: 0, pending: 0, completed: 0, accepted: 0, rejected: 0 });
// -- Group detail modal state -----------------------------
showDetailModal = signal(false);
selectedGroup = signal<any>(null);
// -- Per-candidate (member) detail modal ------------------
showMemberDetailModal = signal(false);
selectedMember = signal<any>(null);
memberEvalComment = '';
memberEvalRecommendation = '';
isMemberSubmitting = signal(false);
isPdfGenerating = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
ngOnInit(): void {
this.loadInterviews();
this.loadStats();
}
loadInterviews(): void {
this.loading.set(true);
const params: any = { type: 'group' };
if (this.filterStatus) params.status = this.filterStatus;
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) });
}
onFilterChange(): void { this.loadInterviews(); }
// -- Group detail modal ------------------------------------
openDetail(group: any): void {
this.selectedGroup.set(group);
this.showDetailModal.set(true);
}
closeDetail(): void {
this.showDetailModal.set(false);
this.selectedGroup.set(null);
}
// -- Member (candidate) detail modal ----------------------
openMemberDetail(interviewId: string, event: Event): void {
event.stopPropagation();
this.quizService.getInterviewById(interviewId).subscribe({
next: res => {
this.selectedMember.set(res.interview);
this.memberEvalComment = '';
this.memberEvalRecommendation = '';
this.showMemberDetailModal.set(true);
}
});
}
closeMemberDetail(): void {
this.showMemberDetailModal.set(false);
this.selectedMember.set(null);
}
submitMemberEvaluation(): void {
const m = this.selectedMember();
if (!m || !this.memberEvalRecommendation) return;
this.isMemberSubmitting.set(true);
this.quizService.submitEvaluation(m._id, {
comments: this.memberEvalComment,
recommendation: this.memberEvalRecommendation
}).subscribe({
next: res => {
this.selectedMember.set(res.interview);
this.memberEvalComment = '';
this.memberEvalRecommendation = '';
this.isMemberSubmitting.set(false);
this.loadInterviews();
},
error: () => this.isMemberSubmitting.set(false)
});
}
hasMemberEvaluated(): boolean {
const m = this.selectedMember();
if (!m) return false;
const userId = this.authService.currentUser()?.id;
return m.evaluations?.some((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId);
}
allMemberEvaluationsDone(): boolean {
const m = this.selectedMember();
if (!m) return false;
const total = (m.assignedInterviewers?.length || 0) +
(m.assignedHRs?.length || 0) +
(m.assignedPMs?.length || 0);
if (total === 0) return false;
return (m.evaluations?.length || 0) >= total;
}
needsEvaluation(m: any): boolean {
if (m.status === 'completed') return false;
const userId = this.authService.currentUser()?.id;
const evaluated = m.evaluations?.some(
(e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId
);
return !evaluated;
}
hasPendingEvaluations(m: any): boolean {
if (m.status === 'completed') return false;
const total = (m.assignedInterviewers?.length || 0) +
(m.assignedHRs?.length || 0) +
(m.assignedPMs?.length || 0);
return (m.evaluations?.length || 0) < total;
}
downloadEvaluationPdf(): void {
const m = this.selectedMember();
if (!m) return;
this.isPdfGenerating.set(true);
setTimeout(() => {
window.print();
this.isPdfGenerating.set(false);
}, 500);
}
// -- Helpers -----------------------------------------------
getStatusClass(status: string): string {
const map: any = {
pending: 'badge-warning', quiz_phase: 'badge-info',
coding_phase: 'badge-info', evaluation: 'badge-purple', completed: 'badge-success'
};
return map[status] || '';
}
getDecisionClass(decision: string): string {
const map: any = {
accepted: 'badge-success', rejected: 'badge-danger',
on_hold: 'badge-warning', '2nd_round': 'badge-info'
};
return map[decision] || 'badge-muted';
}
formatStatus(status: string): string {
return (status || '').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
formatDecision(d: string): string {
const m: any = { pending: 'Pending', accepted: 'Accepted', rejected: 'Rejected', on_hold: 'On Hold', '2nd_round': '2nd Round' };
return m[d] || d;
}
groupStatusSummary(members: any[]): string {
const counts: any = {};
for (const m of members) counts[m.status] = (counts[m.status] || 0) + 1;
return Object.entries(counts).map(([s, c]) => this.formatStatus(s) + ': ' + c).join(' · ');
}
pendingCount(members: any[]): number { return members.filter(m => m.status !== 'completed').length; }
completedCount(members: any[]): number { return members.filter(m => m.status === 'completed').length; }
// -- Quick-evaluate dropdown ---------------------------
expandedGroups = new Set<string>();
toggleExpand(groupId: string, event: Event): void {
event.stopPropagation();
if (this.expandedGroups.has(groupId)) {
this.expandedGroups.delete(groupId);
} else {
this.expandedGroups.add(groupId);
}
}
isExpanded(groupId: string): boolean {
return this.expandedGroups.has(groupId);
}
groupHasPendingEvals(members: any[]): boolean {
const userId = this.authService.currentUser()?.id;
return members.some(m =>
m.status !== 'completed' &&
!m.evaluations?.some((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId)
);
}
getVisibleEvaluations(evaluations: any[]): any[] {
if (!evaluations) return [];
const userId = this.authService.currentUser()?.id;
return evaluations.filter((e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId);
}
}
...@@ -49,6 +49,60 @@ ...@@ -49,6 +49,60 @@
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; } .iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.iv-badges { display: flex; gap: 8px; flex-wrap: wrap; } .iv-badges { display: flex; gap: 8px; flex-wrap: wrap; }
/* Card right section — badges + toggle btn */
.iv-card-right {
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
/* Evaluate toggle button */
.eval-toggle-wrap {
position: relative; display: flex; align-items: center;
}
.eval-toggle-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 10px; font-size: 13px; font-weight: 600;
border: 1.5px solid rgba(102,126,234,0.35);
background: rgba(102,126,234,0.07);
color: #667eea; cursor: pointer; font-family: inherit;
transition: all 0.2s; position: relative;
}
.eval-toggle-btn:hover {
background: rgba(102,126,234,0.15);
border-color: #667eea;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102,126,234,0.2);
}
.eval-toggle-btn.open {
background: rgba(102,126,234,0.15);
border-color: #667eea;
}
.eval-toggle-btn .material-symbols-rounded { font-size: 18px; }
.eval-toggle-label { letter-spacing: 0.2px; }
/* Pending count badge on button */
.eval-badge-count {
background: #ef4444; color: #fff;
font-size: 10px; font-weight: 700; line-height: 1;
min-width: 16px; height: 16px;
border-radius: 8px; padding: 0 4px;
display: inline-flex; align-items: center; justify-content: center;
}
/* Pulsing red alert dot */
.eval-alert-dot {
position: absolute; top: -4px; right: -4px; z-index: 2;
width: 10px; height: 10px; border-radius: 50%;
background: #ef4444;
box-shadow: 0 0 0 0 rgba(239,68,68,0.6);
animation: pulse-dot 1.6s ease-in-out infinite;
}
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.6); }
70% { box-shadow: 0 0 0 7px rgba(239,68,68,0); }
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
}
.iv-card-meta { .iv-card-meta {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px; display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px; border-bottom: 1px solid var(--border-color);
...@@ -113,6 +167,84 @@ ...@@ -113,6 +167,84 @@
.badge-muted { background: var(--bg-hover); color: var(--text-muted); } .badge-muted { background: var(--bg-hover); color: var(--text-muted); }
.badge-group { background: linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.15)); color: #667eea; } .badge-group { background: linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.15)); color: #667eea; }
/* ═══════════════════════════════════════════════════════
QUICK EVALUATE PANEL
═══════════════════════════════════════════════════════ */
.quick-eval-panel {
margin: 4px 0 8px;
border: 1.5px solid rgba(102,126,234,0.25);
border-radius: 14px;
background: rgba(102,126,234,0.04);
overflow: hidden;
animation: slideDown 0.2s cubic-bezier(0.16,1,0.3,1);
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.qep-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
font-size: 12px; font-weight: 700; color: #667eea;
text-transform: uppercase; letter-spacing: 0.6px;
border-bottom: 1px solid rgba(102,126,234,0.15);
}
.qep-header .material-symbols-rounded { font-size: 16px; }
.qep-row {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; gap: 12px;
border-top: 1px solid rgba(102,126,234,0.08);
transition: background 0.15s;
}
.qep-row:first-of-type { border-top: none; }
.qep-row:hover { background: rgba(102,126,234,0.06); }
.qep-row.qep-done { opacity: 0.65; }
.qep-candidate { display: flex; align-items: center; gap: 10px; }
.qep-avatar {
width: 34px; height: 34px; border-radius: 9px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff; font-size: 14px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.qep-avatar.badge-warning { background: linear-gradient(135deg, #f59e0b, #d97706); }
.qep-avatar.badge-info { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.qep-avatar.badge-success { background: linear-gradient(135deg, #22c55e, #16a34a); }
.qep-avatar.badge-purple { background: linear-gradient(135deg, #a855f7, #7c3aed); }
.qep-info { display: flex; flex-direction: column; gap: 1px; }
.qep-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.qep-status { font-size: 11px; color: var(--text-muted); }
.qep-action { flex-shrink: 0; }
/* Evaluate CTA button */
.btn-evaluate {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border-radius: 10px; font-size: 13px; font-weight: 700;
border: none; cursor: pointer; font-family: inherit;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 3px 10px rgba(102,126,234,0.35);
transition: all 0.2s;
}
.btn-evaluate:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(102,126,234,0.45);
}
.btn-evaluate .material-symbols-rounded { font-size: 16px; }
/* Done label */
.qep-done-label {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 600; color: #22c55e;
}
.qep-done-label .material-symbols-rounded { font-size: 16px; }
/* ═══════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════
EMPTY / LOADING EMPTY / LOADING
═══════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════ */
...@@ -338,9 +470,12 @@ ...@@ -338,9 +470,12 @@
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */ /* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.candidate-row-bottom { .candidate-row-bottom {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; display: flex; flex-wrap: nowrap; align-items: center; gap: 8px;
padding-top: 10px; border-top: 1px solid var(--border-color); padding-top: 10px; border-top: 1px solid var(--border-color);
overflow-x: auto; scrollbar-width: none;
} }
.candidate-row-bottom::-webkit-scrollbar { display: none; }
.candidate-row-bottom > * { flex-shrink: 0; }
/* Progress steps (mini) */ /* Progress steps (mini) */
.progress-steps { display: flex; align-items: center; } .progress-steps { display: flex; align-items: center; }
......
This diff is collapsed.
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