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)
const { status, type } = req.query;
let filter = {};
// Evaluators only see their assigned interviews or created by them (unless Admin)
if (req.user.role !== 'admin') {
// Evaluators only see their assigned interviews or created by them (unless Admin/HR)
if (!['admin', 'hr'].includes(req.user.role)) {
filter.$or = [
{ interviewerId: req.user._id },
{ assignedInterviewers: req.user._id },
......@@ -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) => {
try {
let filter = {};
if (req.user.role !== 'admin') {
if (!['admin', 'hr'].includes(req.user.role)) {
filter.$or = [
{ interviewerId: req.user._id },
{ assignedInterviewers: req.user._id },
......@@ -314,14 +314,18 @@ router.get('/:id', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, re
}
// Check authorization to view
if (req.user.role !== 'admin') {
const isAuthorized =
(interview.interviewerId && interview.interviewerId._id.toString() === req.user._id.toString()) ||
interview.assignedInterviewers?.some(u => u.toString() === req.user._id.toString()) ||
interview.assignedHRs?.some(u => u.toString() === req.user._id.toString()) ||
interview.assignedPMs?.some(u => u.toString() === req.user._id.toString()) ||
(interview.createdBy && interview.createdBy._id.toString() === req.user._id.toString());
// 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 =
(interview.interviewerId && getId(interview.interviewerId) === userId) ||
interview.assignedInterviewers?.some(u => getId(u) === userId) ||
interview.assignedHRs?.some(u => getId(u) === userId) ||
interview.assignedPMs?.some(u => getId(u) === userId) ||
(interview.createdBy && getId(interview.createdBy) === userId);
if (!isAuthorized) {
return res.status(403).json({ message: 'Not authorized to view this interview' });
}
......
......@@ -125,7 +125,11 @@ export const routes: Routes = [
},
{
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',
......@@ -143,11 +147,15 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
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',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
path: 'individual-interview',
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',
......@@ -165,11 +173,15 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
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',
loadComponent: () => import('./pages/admin/individual-interview/individual-interview').then(m => m.IndividualInterviewComponent)
path: 'individual-interview',
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',
......
......@@ -180,7 +180,7 @@
<a routerLink="/admin/individual-interview" class="glassy-option"
<a [routerLink]="getIndividualInterviewRoute()" class="glassy-option"
(click)="uiService.showInterviewPopup.set(false)">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">
......@@ -198,7 +198,7 @@
</span>
</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">
<span class="material-symbols-rounded block-icon">
groups
......
......@@ -56,13 +56,15 @@ export class LayoutComponent {
case 'pm':
return [
{ 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' },
];
case 'interviewer':
return [
{ 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' },
];
case 'candidate':
......@@ -128,4 +130,20 @@ export class LayoutComponent {
getManageGroupsRoute(): string {
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);
}
}
......@@ -72,13 +72,28 @@
<p class="iv-email">{{ g.members.length }} candidate{{ g.members.length !== 1 ? 's' : '' }} · {{ g.position }}</p>
</div>
</div>
<div class="iv-badges">
<div class="iv-card-right">
<span class="badge badge-group">Group</span>
@if (completedCount(g.members) === g.members.length && g.members.length > 0) {
<span class="badge badge-success">All Done</span>
} @else {
<span class="badge badge-warning">In Progress</span>
}
<!-- Dropdown toggle button with alert indicator -->
<div class="eval-toggle-wrap">
@if (groupHasPendingEvals(g.members)) {
<span class="eval-alert-dot" title="You have pending evaluations"></span>
}
<button class="eval-toggle-btn" [class.open]="isExpanded(g.groupId)"
(click)="toggleExpand(g.groupId, $event)"
[title]="isExpanded(g.groupId) ? 'Hide candidates' : 'Quick Evaluate'">
<span class="material-symbols-rounded">{{ isExpanded(g.groupId) ? 'expand_less' : 'how_to_reg' }}</span>
<span class="eval-toggle-label">{{ isExpanded(g.groupId) ? 'Close' : 'Evaluate' }}</span>
@if (groupHasPendingEvals(g.members) && !isExpanded(g.groupId)) {
<span class="eval-badge-count">{{ g.members.length }}</span>
}
</button>
</div>
</div>
</div>
......@@ -93,22 +108,42 @@
}
</div>
<!-- Candidate progress chips — click any chip to open the evaluation panel directly -->
<div class="candidate-chips">
@for (m of g.members; track m._id) {
<span class="candidate-chip clickable-chip" [ngClass]="getStatusClass(m.status)"
[title]="m.candidateId?.name + ' — ' + formatStatus(m.status) + (hasPendingEvaluations(m) ? ' (Evaluations pending)' : '')"
(click)="openMemberDetail(m._id, $event)">
@if (hasPendingEvaluations(m)) {
<span class="chip-alert" title="Evaluations still pending">⚠️</span>
}
{{ m.candidateId?.name }}
</span>
}
</div>
<!-- Quick-evaluate dropdown panel -->
@if (isExpanded(g.groupId)) {
<div class="quick-eval-panel" (click)="$event.stopPropagation()">
<div class="qep-header">
<span class="material-symbols-rounded">how_to_reg</span>
<span>Select a candidate to evaluate</span>
</div>
@for (m of g.members; track m._id) {
<div class="qep-row" [class.qep-done]="!needsEvaluation(m)">
<div class="qep-candidate">
<div class="qep-avatar" [ngClass]="getStatusClass(m.status)">
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}
</div>
<div class="qep-info">
<span class="qep-name">{{ m.candidateId?.name }}</span>
<span class="qep-status">{{ formatStatus(m.status) }}</span>
</div>
</div>
<div class="qep-action">
@if (needsEvaluation(m)) {
<button class="btn-evaluate" (click)="openMemberDetail(m._id, $event)">
<span class="material-symbols-rounded">rate_review</span>
Evaluate
</button>
} @else {
<span class="qep-done-label">
<span class="material-symbols-rounded">check_circle</span>
Done
</span>
}
</div>
</div>
}
</div>
}
<div class="iv-card-bottom">
<span class="status-summary">{{ groupStatusSummary(g.members) }}</span>
</div>
......@@ -413,21 +448,15 @@
<!-- Top row: clickable avatar+name on the left, progress bar on the right -->
<div class="candidate-row-top">
<button class="cr-clickable" (click)="openMemberDetail(m._id, $event)">
<div class="cr-clickable" style="cursor: default;">
<div class="iv-avatar small">{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}</div>
<div>
<div class="cr-name">
{{ m.candidateId?.name }}
@if (needsEvaluation(m)) {
<span class="eval-pending-badge" title="Your evaluation is pending">⚠️ Evaluate</span>
}
@if (hasPendingEvaluations(m)) {
<span class="eval-pending-badge warn" title="Some evaluations are still pending"></span>
}
</div>
<div class="cr-email">{{ m.candidateId?.email }}</div>
</div>
</button>
</div>
<div class="candidate-row-mid">
<div class="progress-steps mini">
<div class="step" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)">
......@@ -453,20 +482,22 @@
</div>
</div>
<!-- Bottom row: status badge + quiz scores (no accept/reject here) -->
<div class="candidate-row-bottom">
<span class="badge" [ngClass]="getStatusClass(m.status)">{{ formatStatus(m.status) }}</span>
@if (m.finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(m.finalDecision)">{{ formatDecision(m.finalDecision) }}</span>
}
@if (m.quizzes?.length > 0) {
@for (q of m.quizzes; track q.quizId) {
<span class="quiz-score-chip" [class.completed]="q.completed">
{{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }}
</span>
<!-- Bottom row: status badge + final decision -->
<div class="candidate-row-bottom" style="display: flex; justify-content: space-between; width: 100%;">
<div style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 4px; border-radius: 4px;" (click)="openMemberDetail(m._id, $event)">
<span style="font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Current Status:</span>
<span class="badge" [ngClass]="getStatusClass(m.status)">{{ formatStatus(m.status) }}</span>
@if (needsEvaluation(m)) {
<span class="eval-pending-badge" title="Your evaluation is pending" style="margin-left: 4px;">⚠️ Evaluate</span>
}
</div>
@if (m.finalDecision !== 'pending') {
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Overall Status:</span>
<span class="badge" [ngClass]="getDecisionClass(m.finalDecision)">{{ formatDecision(m.finalDecision) }}</span>
</div>
}
<span class="cr-open-hint">Click name to evaluate →</span>
</div>
</div>
......@@ -598,7 +629,7 @@
}
<!-- Add evaluation form (HR / PM / Interviewer, not if already submitted or completed) -->
@if (!hasMemberEvaluated() && selectedMember().status !== 'completed') {
@if (!hasMemberEvaluated() && selectedMember().status !== 'completed' && authService.getUserRole() !== 'admin') {
<div class="eval-form">
<h4>Add Your Evaluation</h4>
<div class="form-group">
......
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