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,13 +314,17 @@ router.get('/:id', authorize('admin', 'hr', 'pm', 'interviewer'), async (req, re
}
// 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 =
(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());
(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)
});
}
}
/* ═══════════════════════════════════════════════════════
PAGE LAYOUT
═══════════════════════════════════════════════════════ */
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1200px; margin: 0; }
.page-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px;
}
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
/* Stats */
.stats-row { display: flex; gap: 16px; margin-bottom: 24px; }
.mini-stat { flex: 1; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px 20px; text-align: center; }
.mini-stat-value { display: block; font-size: 28px; font-weight: 700; color: var(--text-primary); }
.mini-stat-value.orange { color: #f59e0b; }
.mini-stat-value.blue { color: #3b82f6; }
.mini-stat-value.green { color: #22c55e; }
.mini-stat-value.red { color: #ef4444; }
.mini-stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
/* Filter */
.filter-bar { margin-bottom: 20px; }
.filter-select {
padding: 8px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
/* ═══════════════════════════════════════════════════════
INTERVIEW CARDS
═══════════════════════════════════════════════════════ */
.interview-list { display: flex; flex-direction: column; gap: 16px; }
.interview-card {
cursor: pointer; transition: all 0.2s; border: 1px solid var(--border-color);
border-radius: 16px; background: var(--bg-card);
}
.interview-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 20px rgba(102,126,234,0.12); transform: translateY(-1px); }
.iv-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.iv-group-info { display: flex; align-items: center; gap: 14px; }
.iv-group-avatar {
width: 48px; height: 48px; border-radius: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.iv-group-avatar .material-symbols-rounded { color: #fff; font-size: 26px; }
.iv-name { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 0; }
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.iv-badges { display: flex; gap: 8px; flex-wrap: wrap; }
.iv-card-meta {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color);
}
.meta-item { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Candidate pills row */
.candidate-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.candidate-pill {
display: flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px;
border-radius: 20px; background: var(--bg-hover); border: 1px solid var(--border-color);
transition: all 0.2s;
}
.candidate-pill:hover { border-color: rgba(102,126,234,0.4); transform: translateY(-1px); }
.cp-avatar {
width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 11px; font-weight: 700; color: #fff; background: #667eea;
}
.cp-name { font-size: 12px; font-weight: 500; color: var(--text-primary); }
.candidate-pill.badge-warning .cp-avatar { background: #f59e0b; }
.candidate-pill.badge-info .cp-avatar { background: #3b82f6; }
.candidate-pill.badge-success .cp-avatar { background: #22c55e; }
.candidate-pill.badge-purple .cp-avatar { background: #a855f7; }
/* 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); }
}
/* ⚠ badge on chip (kept for modal usage) */
.chip-alert { font-size: 11px; margin-right: 3px; }
/* Evaluation pending badge inside candidate row name */
.eval-pending-badge {
display: inline-flex; align-items: center; font-size: 11px; font-weight: 600;
padding: 2px 7px; border-radius: 10px; margin-left: 8px;
background: rgba(245,158,11,0.15); color: #f59e0b;
}
.eval-pending-badge.warn { background: rgba(102,126,234,0.1); color: #667eea; }
/* Clickable candidate left section (button reset) */
.cr-clickable {
display: flex; align-items: center; gap: 12px;
background: none; border: none; text-align: left;
padding: 6px 10px; border-radius: 10px; transition: background 0.15s;
min-width: 160px; flex-shrink: 0;
}
/* Hint text at end of bottom row */
.cr-open-hint { font-size: 11px; color: var(--text-muted); margin-left: auto; font-style: italic; }
/* ─── iv-card-bottom ─────────────────────────────────────── */
.iv-card-bottom { display: flex; justify-content: flex-start; margin-top: 10px; }
.status-summary { font-size: 12px; color: var(--text-muted); font-style: italic; }
/* ═══════════════════════════════════════════════════════
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; }
.badge { padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.badge-warning { background: rgba(245,158,11,0.1); color: #f59e0b; }
.badge-info { background: rgba(59,130,246,0.1); color: #3b82f6; }
.badge-purple { background: rgba(168,85,247,0.1); color: #a855f7; }
.badge-success { background: rgba(34,197,94,0.1); color: #22c55e; }
.badge-danger { background: rgba(239,68,68,0.1); color: #ef4444; }
.badge-muted { background: var(--bg-hover); color: var(--text-muted); }
.badge-group { background: linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.15)); color: #667eea; }
/* ═══════════════════════════════════════════════════════
EMPTY / LOADING
═══════════════════════════════════════════════════════ */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px; gap: 16px; color: var(--text-muted); }
.empty-state { text-align: center; padding: 80px; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 64px; display: block; margin-bottom: 16px; opacity: 0.35; }
.empty-state h3 { color: var(--text-primary); font-size: 18px; margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
/* ═══════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(5px);
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: 18px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3); border: 1px solid var(--border-color);
width: 92%; max-height: 88vh; overflow-y: auto;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-xl { max-width: 860px; }
.modal-header {
padding: 22px 26px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; background: var(--bg-card); z-index: 2; border-radius: 18px 18px 0 0;
}
.modal-header h2 { font-size: 18px; font-weight: 700; margin: 0; }
.modal-body { padding: 26px; display: flex; flex-direction: column; gap: 8px; }
.modal-footer {
padding: 16px 26px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px;
background: var(--bg-hover); position: sticky; bottom: 0; border-radius: 0 0 18px 18px;
}
/* Success banner */
.success-banner {
margin: 24px; padding: 20px 24px; border-radius: 14px;
background: linear-gradient(135deg, rgba(34,197,94,.12), rgba(16,185,129,.08));
border: 1px solid rgba(34,197,94,.3); color: #22c55e;
display: flex; align-items: center; gap: 12px; font-size: 15px; font-weight: 500;
}
.success-banner .material-symbols-rounded { font-size: 28px; }
.success-banner .btn { margin-left: auto; }
/* ═══════════════════════════════════════════════════════
FORM ELEMENTS
═══════════════════════════════════════════════════════ */
.section-title {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 700; color: var(--text-primary);
text-transform: uppercase; letter-spacing: 0.8px;
padding: 10px 0 6px; border-bottom: 1px solid var(--border-color); margin-bottom: 14px; margin-top: 10px;
}
.section-title .material-symbols-rounded { font-size: 18px; color: var(--accent-primary); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 14px; }
.form-row.three-col { grid-template-columns: 1fr 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px;
font-family: inherit; transition: all 0.2s; width: 100%; box-sizing: border-box;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.12); }
.field-hint {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; color: #22c55e; margin-top: 2px;
}
.field-hint .material-symbols-rounded { font-size: 14px; }
/* Checklist box */
.checklist-box {
border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px;
max-height: 130px; overflow-y: auto; background: var(--bg-input);
display: flex; flex-direction: column; gap: 6px;
}
.check-item {
display: flex; align-items: center; gap: 8px; font-size: 13px;
color: var(--text-secondary); cursor: pointer; padding: 2px 0;
}
.check-item input[type="checkbox"] { accent-color: var(--accent-primary); }
.check-item:hover { color: var(--text-primary); }
.text-muted { color: var(--text-muted); font-size: 13px; }
/* ═══════════════════════════════════════════════════════
QUIZ CONFIGURATION
═══════════════════════════════════════════════════════ */
.quiz-empty-hint {
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 32px; border: 2px dashed var(--border-color); border-radius: 14px;
background: var(--bg-hover); color: var(--text-muted); margin-bottom: 8px;
}
.quiz-empty-hint .material-symbols-rounded { font-size: 40px; opacity: 0.4; }
.quiz-empty-hint p { margin: 0; font-size: 14px; }
.quiz-setup-prompt {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 14px 18px; border-radius: 10px;
background: rgba(102,126,234,0.06); border: 1px solid rgba(102,126,234,0.2);
margin-bottom: 16px;
}
.quiz-setup-prompt .material-symbols-rounded { font-size: 20px; color: var(--accent-primary); }
.quiz-setup-prompt span { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.sets-count-input {
width: 72px; padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px;
text-align: center; font-family: inherit;
}
.sets-count-input:focus { outline: none; border-color: var(--accent-primary); }
/* Quiz Set Block */
.quiz-set-block {
border: 1px solid var(--border-color); border-radius: 14px;
padding: 18px 20px; margin-bottom: 14px;
background: var(--bg-hover); display: flex; flex-direction: column; gap: 10px;
transition: border-color 0.2s;
}
.quiz-set-block:hover { border-color: rgba(102,126,234,0.4); }
.quiz-set-header { display: flex; align-items: center; justify-content: space-between; }
.quiz-set-title { display: flex; align-items: center; gap: 10px; }
.set-badge {
background: linear-gradient(135deg, #667eea, #764ba2); color: #fff;
font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 20px;
}
.set-note { font-size: 12px; color: var(--text-muted); }
/* Quiz entry row */
.quiz-entry-row {
display: flex; align-items: center; gap: 10px;
}
.quiz-entry-row .form-input { flex: 1; }
/* Assignment mode */
.assignment-mode-row {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding-top: 8px; border-top: 1px solid var(--border-color);
}
.mode-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.mode-toggle { display: flex; gap: 8px; }
.mode-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border-color); background: var(--bg-input);
color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
}
.mode-btn .material-symbols-rounded { font-size: 16px; }
.mode-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.mode-btn.active { border-color: #667eea; background: rgba(102,126,234,0.12); color: #667eea; font-weight: 700; }
/* Direct assignment table */
.direct-assignment-table {
border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden;
margin-top: 4px;
}
.da-header {
display: grid; grid-template-columns: 1fr 1.5fr;
padding: 10px 14px; background: rgba(102,126,234,0.08);
font-size: 12px; font-weight: 700; color: var(--text-primary); text-transform: uppercase; letter-spacing: 0.5px;
}
.da-row {
display: grid; grid-template-columns: 1fr 1.5fr; align-items: center;
padding: 10px 14px; border-top: 1px solid var(--border-color);
gap: 12px; background: var(--bg-card); transition: background 0.15s;
}
.da-row:hover { background: var(--bg-hover); }
.da-candidate { display: flex; align-items: center; gap: 10px; }
.da-avatar {
width: 30px; height: 30px; border-radius: 8px; font-size: 13px; font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2); color: #fff;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.da-row .form-input { padding: 7px 10px; font-size: 13px; }
/* Ghost btn */
.btn-ghost {
background: none; border: 1px dashed var(--border-color); color: var(--text-muted);
padding: 7px 14px; border-radius: 8px; font-size: 13px; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: all 0.2s;
font-family: inherit;
}
.btn-ghost:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
.btn-ghost .material-symbols-rounded { font-size: 16px; }
.icon-btn.danger { color: #ef4444; }
.icon-btn.danger:hover { background: rgba(239,68,68,0.08); }
/* ═══════════════════════════════════════════════════════
DETAIL MODAL
═══════════════════════════════════════════════════════ */
.detail-body { display: flex; flex-direction: column; gap: 24px; }
.detail-section { padding-bottom: 16px; border-bottom: 1px solid var(--border-color); }
.detail-section:last-child { border-bottom: none; }
.detail-section-title { font-size: 12px; font-weight: 700; color: var(--text-primary); margin: 0 0 16px; text-transform: uppercase; letter-spacing: 0.8px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-item { display: flex; flex-direction: column; gap: 3px; }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
/* Candidate detail list */
.candidate-detail-list { display: flex; flex-direction: column; gap: 12px; }
.candidate-row {
display: flex; flex-direction: column; gap: 12px; padding: 16px;
border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-hover);
transition: border-color 0.2s;
}
.candidate-row:hover { border-color: rgba(102,126,234,0.35); }
/* Top row: avatar+name on left, progress stepper fills the rest */
.candidate-row-top { display: flex; align-items: center; gap: 16px; }
.candidate-row-left { display: flex; align-items: center; gap: 12px; min-width: 160px; flex-shrink: 0; }
.iv-avatar { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg,#667eea,#764ba2); color:#fff; font-weight:700; font-size:16px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.iv-avatar.small { width: 36px; height: 36px; font-size: 15px; border-radius: 9px; }
.cr-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.cr-email { font-size: 12px; color: var(--text-muted); }
.candidate-row-mid { flex: 1; }
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.candidate-row-bottom {
display: flex; flex-wrap: nowrap; align-items: center; gap: 8px;
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 { display: flex; align-items: center; }
.progress-steps.mini .step-label { font-size: 10px; }
.progress-steps.mini .step-dot { width: 8px; height: 8px; }
.step { display: flex; align-items: center; gap: 4px; }
.step-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--border-color); transition: all 0.3s; }
.step.done .step-dot { background: #22c55e; }
.step.active .step-dot { background: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.2); }
.step-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.step.done .step-label { color: #22c55e; }
.step.active .step-label { color: #667eea; font-weight: 600; }
.step-line { flex: 1; height: 2px; background: var(--border-color); margin: 0 6px; transition: background 0.3s; min-width: 16px; }
.step-line.done { background: #22c55e; }
/* Quiz score chips */
.quiz-scores-inline { display: flex; flex-wrap: wrap; gap: 6px; }
.quiz-score-chip {
font-size: 11px; padding: 3px 10px; border-radius: 20px;
background: var(--bg-card); border: 1px solid var(--border-color);
color: var(--text-muted); white-space: nowrap;
}
.quiz-score-chip.completed { color: #22c55e; border-color: rgba(34,197,94,0.3); background: rgba(34,197,94,0.07); }
/* Mini decision buttons */
.mini-decision-btns { display: flex; gap: 6px; }
.mini-btn {
padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
border: none; cursor: pointer; transition: opacity 0.2s; font-family: inherit;
}
.mini-btn.success { background: rgba(34,197,94,0.15); color: #22c55e; }
.mini-btn.success:hover { background: #22c55e; color: #fff; }
.mini-btn.danger { background: rgba(239,68,68,0.12); color: #ef4444; }
.mini-btn.danger:hover { background: #ef4444; color: #fff; }
/* ═══════════════════════════════════════════════════════
BUTTONS (shared)
═══════════════════════════════════════════════════════ */
.btn-success { background: #22c55e; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(24px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ═══════════════════════════════════════════════════════
EVALUATION PANEL (member detail modal)
Mirrors individual-interview styles
═══════════════════════════════════════════════════════ */
.quiz-results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.quiz-result-card {
padding: 14px 16px; border-radius: 12px; border: 1px solid var(--border-color);
background: var(--bg-input);
}
.qr-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.qr-score { font-size: 22px; font-weight: 700; color: #22c55e; }
.qr-pending { font-size: 13px; color: var(--text-muted); font-style: italic; }
.eval-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
.eval-card {
padding: 14px 16px; border-radius: 12px; border: 1px solid var(--border-color);
background: var(--bg-input);
}
.eval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.eval-evaluator { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.eval-role { font-size: 10px !important; padding: 2px 6px !important; }
.eval-comments { font-size: 13px; color: var(--text-secondary); font-style: italic; margin: 6px 0 8px; }
.eval-date { font-size: 11px; color: var(--text-muted); }
.eval-form {
margin-top: 16px; padding: 18px 20px; border-radius: 14px;
border: 1px dashed rgba(102,126,234,0.35);
background: rgba(102,126,234,0.04);
display: flex; flex-direction: column; gap: 12px;
}
.eval-form h4 { font-size: 14px; font-weight: 700; color: var(--text-primary); margin: 0; }
.form-textarea { resize: vertical; min-height: 80px; }
.decision-section { border-bottom: none; }
.decision-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
.btn-success { background: #22c55e; color: #fff; border: none; }
.btn-warning { background: #f59e0b; color: #fff; border: none; }
/* ═══════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════ */
@media (max-width: 768px) {
.page-container { padding: 20px; }
.stats-row { flex-wrap: wrap; }
.mini-stat { min-width: 100px; }
.form-row, .form-row.three-col { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr; }
.candidate-row { flex-direction: column; }
.candidate-row-right { align-items: flex-start; }
.da-header, .da-row { grid-template-columns: 1fr; }
}
/* ═══════════════════════════════════════════════════════
PRINT STYLES FOR EVALUATION PDF
═══════════════════════════════════════════════════════ */
.print-container {
display: none;
}
@media print {
body * {
visibility: hidden;
}
.print-container, .print-container * {
visibility: visible;
}
.print-container {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
background: white;
color: black;
font-family: Arial, sans-serif;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #0078d4;
padding-bottom: 10px;
margin-bottom: 20px;
}
}
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<!-- Page Header -->
<div class="page-header">
<div>
<h1>Group Interviews</h1>
<p class="page-subtitle">Create and manage batch interview sessions for candidate groups</p>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ stats().total }}</span>
<span class="mini-stat-label">Total</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value orange">{{ stats().pending }}</span>
<span class="mini-stat-label">In Progress</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value blue">{{ stats().completed }}</span>
<span class="mini-stat-label">Completed</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value green">{{ stats().accepted }}</span>
<span class="mini-stat-label">Accepted</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value red">{{ stats().rejected }}</span>
<span class="mini-stat-label">Rejected</span>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<select class="filter-select" [(ngModel)]="filterStatus" (ngModelChange)="onFilterChange()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="quiz_phase">Quiz Phase</option>
<option value="coding_phase">Coding Phase</option>
<option value="evaluation">Evaluation</option>
<option value="completed">Completed</option>
</select>
</div>
<!-- Interview Group List -->
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading interviews...</p></div>
} @else if (groupedList().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">groups</span>
<h3>No group interviews found</h3>
<p>Create your first group interview session to get started</p>
</div>
} @else {
<div class="interview-list">
@for (g of groupedList(); track g.groupId) {
<div class="interview-card card card-padding" (click)="openDetail(g)">
<!-- Top row: group info + badges + dropdown toggle -->
<div class="iv-card-top">
<div class="iv-group-info">
<div class="iv-group-avatar">
<span class="material-symbols-rounded">groups</span>
</div>
<div>
<h3 class="iv-name">{{ g.groupId }}</h3>
<p class="iv-email">{{ g.members.length }} candidate{{ g.members.length !== 1 ? 's' : '' }} · {{ g.position }}</p>
</div>
</div>
<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>
<!-- Meta row -->
<div class="iv-card-meta">
<span class="meta-item"><span class="material-symbols-rounded">work</span> {{ g.position }}</span>
@if (g.techStack) {
<span class="meta-item"><span class="material-symbols-rounded">code</span> {{ g.techStack }}</span>
}
<span class="meta-item"><span class="material-symbols-rounded">calendar_today</span> {{ g.dateOfInterview | date:'mediumDate' }}</span>
@if (g.assignedInterviewers?.length > 0) {
<span class="meta-item"><span class="material-symbols-rounded">person</span> {{ g.assignedInterviewers[0]?.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>
</div>
}
</div>
}
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
DETAIL MODAL — All candidates in a group session
═══════════════════════════════════════════════════════════ -->
@if (showDetailModal() && selectedGroup()) {
<div class="modal-overlay" (click)="closeDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div>
<h2>{{ selectedGroup().groupId }}</h2>
<span style="font-size:13px; color:var(--text-muted)">
{{ selectedGroup().position }} · {{ selectedGroup().members.length }} candidates
</span>
</div>
<button class="icon-btn" (click)="closeDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Session meta -->
<div class="detail-section">
<h3 class="detail-section-title">Session Info</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedGroup().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedGroup().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span class="detail-value">{{ selectedGroup().source || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Interview Date</span>
<span class="detail-value">{{ selectedGroup().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Candidate rows -->
<div class="detail-section">
<h3 class="detail-section-title">Candidates</h3>
<div class="candidate-detail-list">
@for (m of selectedGroup().members; track m._id) {
<div class="candidate-row">
<!-- Top row: clickable avatar+name on the left, progress bar on the right -->
<div class="candidate-row-top">
<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 }}
</div>
<div class="cr-email">{{ m.candidateId?.email }}</div>
</div>
</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)">
<span class="step-dot"></span><span class="step-label">Created</span>
</div>
<div class="step-line" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"></div>
<div class="step" [class.active]="m.status==='quiz_phase'" [class.done]="['coding_phase','evaluation','completed'].includes(m.status)">
<span class="step-dot"></span><span class="step-label">Quiz</span>
</div>
<div class="step-line" [class.done]="['coding_phase','evaluation','completed'].includes(m.status)"></div>
<div class="step" [class.active]="m.status==='coding_phase'" [class.done]="['evaluation','completed'].includes(m.status)">
<span class="step-dot"></span><span class="step-label">Coding</span>
</div>
<div class="step-line" [class.done]="['evaluation','completed'].includes(m.status)"></div>
<div class="step" [class.active]="m.status==='evaluation'" [class.done]="m.status==='completed'">
<span class="step-dot"></span><span class="step-label">Evaluate</span>
</div>
<div class="step-line" [class.done]="m.status==='completed'"></div>
<div class="step" [class.active]="m.status==='completed'" [class.done]="m.status==='completed'">
<span class="step-dot"></span><span class="step-label">Done</span>
</div>
</div>
</div>
</div>
<!-- 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>
}
</div>
</div>
}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeDetail()">Close</button>
</div>
</div>
</div>
}
<!-- ═══════════════════════════════════════════════════════════
MEMBER DETAIL MODAL — full evaluation panel for one candidate
═══════════════════════════════════════════════════════════ -->
@if (showMemberDetailModal() && selectedMember()) {
<div class="modal-overlay" (click)="closeMemberDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="modal-header">
<div>
<h2>{{ selectedMember().candidateId?.name }}</h2>
<span class="badge" [ngClass]="getStatusClass(selectedMember().status)">{{ formatStatus(selectedMember().status) }}</span>
@if (selectedMember().finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(selectedMember().finalDecision)" style="margin-left:6px">
{{ formatDecision(selectedMember().finalDecision) }}
</span>
}
</div>
<button class="icon-btn" (click)="closeMemberDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Candidate Info -->
<div class="detail-section">
<h3 class="detail-section-title">Candidate Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Name</span>
<span class="detail-value">{{ selectedMember().candidateId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{ selectedMember().candidateId?.email }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedMember().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedMember().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date of Interview</span>
<span class="detail-value">{{ selectedMember().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedMember().quizzes?.length > 0) {
<div class="detail-section">
<h3 class="detail-section-title">Quiz Results</h3>
<div class="quiz-results-grid">
@for (q of selectedMember().quizzes; track q.quizId) {
<div class="quiz-result-card">
<div class="qr-title">{{ q.title }}</div>
@if (q.completed) {
<div class="qr-score">{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)</div>
} @else {
<div class="qr-pending">Not Taken</div>
}
</div>
}
</div>
</div>
}
<!-- Evaluations -->
<div class="detail-section">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h3 class="detail-section-title" style="margin:0;">Evaluations</h3>
<div style="display: flex; gap: 8px; align-items: center;">
@if (allMemberEvaluationsDone()) {
<span class="badge badge-success">All evaluations done</span>
<button class="btn btn-primary btn-sm" (click)="downloadEvaluationPdf()">
@if (isPdfGenerating()) { <span class="spinner spinner-sm"></span> Generating... } @else { <span class="material-symbols-rounded" style="font-size: 16px; margin-right: 4px;">download</span> Download PDF }
</button>
} @else {
<span class="badge badge-warning">Evaluations pending</span>
}
</div>
</div>
<!-- Evaluation list -->
@if (getVisibleEvaluations(selectedMember().evaluations).length > 0) {
<div class="eval-list">
@for (ev of getVisibleEvaluations(selectedMember().evaluations); track ev._id) {
<div class="eval-card">
<div class="eval-header">
<div class="eval-evaluator">
<strong>{{ ev.evaluatorId?.name }}</strong>
<span class="eval-role badge badge-muted">{{ ev.evaluatorRole | uppercase }}</span>
</div>
<span class="badge" [ngClass]="getDecisionClass(ev.recommendation)">{{ formatDecision(ev.recommendation) }}</span>
</div>
@if (ev.comments) {
<p class="eval-comments">"{{ ev.comments }}"</p>
}
<span class="eval-date">{{ ev.date | date:'medium' }}</span>
</div>
}
</div>
} @else {
<p class="text-muted">No evaluations yet</p>
}
<!-- Add evaluation form (HR / PM / Interviewer, not if already submitted or completed) -->
@if (!hasMemberEvaluated() && selectedMember().status !== 'completed') {
<div class="eval-form">
<h4>Add Your Evaluation</h4>
<div class="form-group">
<label class="form-label">Comments</label>
<textarea class="form-input form-textarea" [(ngModel)]="memberEvalComment"
placeholder="Enter your comments..." rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Recommendation *</label>
<select class="form-input" [(ngModel)]="memberEvalRecommendation">
<option value="">Select recommendation</option>
<option value="offer">Offer / Hire as Intern</option>
<option value="on_hold">On Hold</option>
<option value="rejected">Rejected</option>
<option value="2nd_round">2nd Round</option>
</select>
</div>
<button class="btn btn-primary" (click)="submitMemberEvaluation()"
[disabled]="!memberEvalRecommendation || isMemberSubmitting()">
@if (isMemberSubmitting()) { <span class="spinner spinner-sm"></span> Submitting... }
@else { Submit Evaluation }
</button>
</div>
}
</div>
<!-- Final Decision — Admin and HR, only when all evaluations done -->
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeMemberDetail()">Close</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedMember()) {
<div class="print-container">
<div class="print-header">
<div class="print-header-left">
<h2 style="margin: 0; font-size: 20px;">Intern Interview Evaluation Form</h2>
</div>
<div class="print-header-right" style="text-align: right;">
<span style="color: #0078d4; font-weight: bold; font-size: 24px;">IDEAL</span><br>
<span style="font-size: 10px; color: #555;">TECH LABS</span>
</div>
</div>
<table class="print-table" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Candidate Name:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedMember().candidateId?.name }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Date of Interview:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedMember().dateOfInterview | date:'mediumDate' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Position:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().position }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Source</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().source || '—' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Tech Stack:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().techStack || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Interviewer(s):</td>
<td style="border: 1px solid #000; padding: 8px;">
@if (selectedMember().assignedInterviewers?.length) {
@for (i of selectedMember().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedMember().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[0]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[0]?.completed ? selectedMember().quizzes[0].score + '/' + selectedMember().quizzes[0].totalMarks : 'Not Taken' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[1]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[1]?.completed ? selectedMember().quizzes[1].score + '/' + selectedMember().quizzes[1].totalMarks : 'Not Taken' }}</td>
</tr>
</table>
<!-- Render evaluations -->
@for (ev of selectedMember().evaluations; track ev._id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ ev.comments || 'No comments provided.' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (ev.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + ev.evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ ev.date | date:'shortDate' }}</span>
</div>
</div>
</div>
}
</div>
}
import { Component, OnInit, signal, 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);
}
}
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1200px; margin: 0; }
.page-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px;
}
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
/* Stats Row */
.stats-row {
display: flex; gap: 16px; margin-bottom: 24px;
}
.mini-stat {
flex: 1; background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 12px; padding: 16px 20px; text-align: center;
}
.mini-stat-value { display: block; font-size: 28px; font-weight: 700; color: var(--text-primary); }
.mini-stat-value.orange { color: #f59e0b; }
.mini-stat-value.blue { color: #3b82f6; }
.mini-stat-value.green { color: #22c55e; }
.mini-stat-value.red { color: #ef4444; }
.mini-stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
/* Filter */
.filter-bar { margin-bottom: 20px; }
.filter-select {
padding: 8px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
/* Interview Cards */
.interview-list { display: flex; flex-direction: column; gap: 16px; }
.interview-card {
cursor: pointer; transition: all 0.2s; border: 1px solid var(--border-color);
border-radius: 16px; background: var(--bg-card);
}
.interview-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 16px rgba(102,126,234,0.1); transform: translateY(-1px); }
.iv-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.iv-candidate { display: flex; align-items: center; gap: 14px; }
.iv-avatar {
width: 44px; height: 44px; border-radius: 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px; flex-shrink: 0;
}
.iv-name { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.iv-badges { display: flex; gap: 8px; }
.iv-card-meta {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px; padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
}
.meta-item {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary);
}
.meta-item .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Progress Steps */
.progress-steps { display: flex; align-items: center; }
.step { display: flex; align-items: center; gap: 6px; }
.step-dot {
width: 10px; height: 10px; border-radius: 50%; background: var(--border-color); transition: all 0.3s;
}
.step.done .step-dot { background: #22c55e; }
.step.active .step-dot { background: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.2); }
.step-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.step.done .step-label { color: #22c55e; }
.step.active .step-label { color: #667eea; font-weight: 600; }
.step-line {
flex: 1; height: 2px; background: var(--border-color); margin: 0 8px; transition: background 0.3s;
}
.step-line.done { background: #22c55e; }
/* Badges */
.badge {
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-warning { background: rgba(245,158,11,0.1); color: #f59e0b; }
.badge-info { background: rgba(59,130,246,0.1); color: #3b82f6; }
.badge-purple { background: rgba(168,85,247,0.1); color: #a855f7; }
.badge-success { background: rgba(34,197,94,0.1); color: #22c55e; }
.badge-danger { background: rgba(239,68,68,0.1); color: #ef4444; }
.badge-muted { background: var(--bg-hover); color: var(--text-muted); }
/* Empty / Loading */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px; gap: 16px; color: var(--text-muted); }
.empty-state { text-align: center; padding: 80px; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 56px; display: block; margin-bottom: 16px; opacity: 0.4; }
.empty-state h3 { color: var(--text-primary); font-size: 18px; margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
/* Modal */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); backdrop-filter: blur(4px);
z-index: 1000; display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: var(--bg-card); border-radius: 16px;
box-shadow: 0 10px 50px rgba(0,0,0,0.25); border: 1px solid var(--border-color);
width: 90%; max-height: 85vh; overflow-y: auto;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-lg { max-width: 640px; }
.modal-xl { max-width: 800px; }
.modal-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; background: var(--bg-card); z-index: 2; border-radius: 16px 16px 0 0;
}
.modal-header h2 { font-size: 18px; font-weight: 600; margin: 0 12px 0 0; display: inline; }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px;
background: var(--bg-hover); position: sticky; bottom: 0; border-radius: 0 0 16px 16px;
}
/* Form */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
transition: all 0.2s; width: 100%; box-sizing: border-box;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.form-textarea { resize: vertical; min-height: 80px; }
/* Quiz Selector */
.quiz-select-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.quiz-select-item {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px;
border: 1px solid var(--border-color); border-radius: 8px;
cursor: pointer; font-size: 13px; color: var(--text-secondary); transition: all 0.2s;
}
.quiz-select-item .material-symbols-rounded { font-size: 18px; }
.quiz-select-item.selected { border-color: #667eea; background: rgba(102,126,234,0.08); color: #667eea; }
.quiz-select-item:hover { border-color: var(--accent-primary); }
/* Detail */
.detail-body { display: flex; flex-direction: column; gap: 24px; }
.detail-section { padding-bottom: 16px; border-bottom: 1px solid var(--border-color); }
.detail-section:last-child { border-bottom: none; }
.detail-section-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; text-transform: uppercase; letter-spacing: 0.5px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-item { display: flex; flex-direction: column; gap: 2px; }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
/* Quiz Results */
.quiz-results-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.quiz-result-card {
padding: 14px; border: 1px solid var(--border-color); border-radius: 10px;
background: var(--bg-hover);
}
.qr-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.qr-score { font-size: 14px; color: #22c55e; font-weight: 700; }
.qr-pending { font-size: 13px; color: var(--text-muted); font-style: italic; }
/* Evaluations */
.eval-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.eval-card {
padding: 16px; border: 1px solid var(--border-color); border-radius: 10px;
background: var(--bg-hover);
}
.eval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.eval-evaluator { display: flex; align-items: center; gap: 8px; }
.eval-evaluator strong { font-size: 14px; color: var(--text-primary); }
.eval-role { font-size: 10px; }
.eval-comments { font-size: 14px; color: var(--text-secondary); margin: 8px 0; font-style: italic; line-height: 1.5; }
.eval-date { font-size: 11px; color: var(--text-muted); }
.eval-form {
margin-top: 16px; padding: 20px; border: 1px dashed rgba(102,126,234,0.3);
border-radius: 12px; background: rgba(102,126,234,0.03);
}
.eval-form h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; }
/* Decision */
.decision-section { padding: 16px; background: rgba(102,126,234,0.03); border-radius: 12px; border: 1px dashed rgba(102,126,234,0.2); }
.decision-buttons { display: flex; gap: 12px; flex-wrap: wrap; }
.btn-success { background: #22c55e; color: #fff; }
.btn-success:hover { background: #16a34a; }
.btn-warning { background: #f59e0b; color: #fff; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.text-muted { color: var(--text-muted); font-size: 13px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (max-width: 768px) {
.page-container { padding: 20px; }
.stats-row { flex-wrap: wrap; }
.mini-stat { min-width: 120px; }
.form-row { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr; }
}
/* Print Styles */
.print-container {
display: none;
}
@media print {
body * {
visibility: hidden;
}
.print-container, .print-container * {
visibility: visible;
}
.print-container {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
background: white;
color: black;
font-family: Arial, sans-serif;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #0078d4;
padding-bottom: 10px;
margin-bottom: 20px;
}
}
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<div class="page-header">
<div>
<h1>Individual Interviews</h1>
<p class="page-subtitle">View and evaluate your assigned candidates</p>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ stats().total }}</span>
<span class="mini-stat-label">Total</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value orange">{{ stats().pending }}</span>
<span class="mini-stat-label">In Progress</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value blue">{{ stats().completed }}</span>
<span class="mini-stat-label">Completed</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value green">{{ stats().accepted }}</span>
<span class="mini-stat-label">Accepted</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value red">{{ stats().rejected }}</span>
<span class="mini-stat-label">Rejected</span>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<select class="filter-select" [(ngModel)]="filterStatus" (ngModelChange)="onFilterChange()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="quiz_phase">Quiz Phase</option>
<option value="coding_phase">Coding Phase</option>
<option value="evaluation">Evaluation</option>
<option value="completed">Completed</option>
</select>
</div>
<!-- Interview List -->
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading interviews...</p></div>
} @else if (interviews().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">work_off</span>
<h3>No interviews found</h3>
<p>Create your first interview to get started</p>
</div>
} @else {
<div class="interview-list">
@for (iv of interviews(); track iv._id) {
<div class="interview-card card card-padding" (click)="openDetail(iv)">
<div class="iv-card-top">
<div class="iv-candidate">
<div class="iv-avatar">{{ iv.candidateId?.name?.charAt(0)?.toUpperCase() }}</div>
<div>
<h3 class="iv-name">{{ iv.candidateId?.name }}</h3>
<p class="iv-email">{{ iv.candidateId?.email }}</p>
</div>
</div>
<div class="iv-badges">
<span class="badge" [ngClass]="getStatusClass(iv.status)">{{ formatStatus(iv.status) }}</span>
@if (iv.finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(iv.finalDecision)">{{ formatDecision(iv.finalDecision) }}</span>
}
</div>
</div>
<div class="iv-card-meta">
<span class="meta-item"><span class="material-symbols-rounded">work</span> {{ iv.position }}</span>
@if (iv.techStack) {
<span class="meta-item"><span class="material-symbols-rounded">code</span> {{ iv.techStack }}</span>
}
<span class="meta-item"><span class="material-symbols-rounded">person</span> {{ iv.interviewerId?.name }}</span>
<span class="meta-item"><span class="material-symbols-rounded">calendar_today</span> {{ iv.dateOfInterview | date:'mediumDate' }}</span>
</div>
<div class="iv-card-progress">
<div class="progress-steps">
<div class="step" [class.active]="iv.status === 'pending'" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Created</span>
</div>
<div class="step-line" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'quiz_phase'" [class.done]="['coding_phase','evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Quiz</span>
</div>
<div class="step-line" [class.done]="['coding_phase','evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'coding_phase'" [class.done]="['evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Coding</span>
</div>
<div class="step-line" [class.done]="['evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'evaluation'" [class.done]="iv.status === 'completed'">
<span class="step-dot"></span><span class="step-label">Evaluate</span>
</div>
<div class="step-line" [class.done]="iv.status === 'completed'"></div>
<div class="step" [class.active]="iv.status === 'completed'" [class.done]="iv.status === 'completed'">
<span class="step-dot"></span><span class="step-label">Done</span>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- Interview Detail Modal -->
@if (showDetailModal() && selectedInterview()) {
<div class="modal-overlay" (click)="closeDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div>
<h2>Interview Detail</h2>
<span class="badge" [ngClass]="getStatusClass(selectedInterview().status)">{{ formatStatus(selectedInterview().status) }}</span>
@if (selectedInterview().finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(selectedInterview().finalDecision)" style="margin-left:8px">{{ formatDecision(selectedInterview().finalDecision) }}</span>
}
</div>
<button class="icon-btn" (click)="closeDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Candidate Info -->
<div class="detail-section">
<h3 class="detail-section-title">Candidate Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Name</span>
<span class="detail-value">{{ selectedInterview().candidateId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{ selectedInterview().candidateId?.email }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedInterview().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedInterview().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Interviewer</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date of Interview</span>
<span class="detail-value">{{ selectedInterview().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedInterview().quizzes?.length > 0) {
<div class="detail-section">
<h3 class="detail-section-title">Quiz Results</h3>
<div class="quiz-results-grid">
@for (q of selectedInterview().quizzes; track q.quizId) {
<div class="quiz-result-card">
<div class="qr-title">{{ q.title }}</div>
@if (q.completed) {
<div class="qr-score">{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)</div>
} @else {
<div class="qr-pending">Not Taken</div>
}
</div>
}
</div>
</div>
}
<!-- Coding Round Submission -->
<div class="detail-section">
<h3 class="detail-section-title">Coding Challenge Submission</h3>
@if (selectedInterview().codingRound?.zipFile) {
<div class="eval-card" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span class="material-symbols-rounded" style="color: var(--accent-primary); vertical-align: middle; margin-right: 8px; font-size: 24px;">folder_zip</span>
<strong>Submitted on:</strong> {{ selectedInterview().codingRound.submittedAt | date:'medium' }}
</div>
<div style="display: flex; gap: 12px; align-items: center;">
@if (selectedInterview().codingRound.validated) {
<span class="badge badge-success">Validated by {{ selectedInterview().codingRound.validatedBy?.name || 'Reviewer' }}</span>
}
<a [href]="'http://localhost:5000' + selectedInterview().codingRound.zipFile" target="_blank" class="btn btn-primary" style="padding: 6px 12px; font-size: 14px; border-radius: 6px;">
<span class="material-symbols-rounded" style="font-size: 18px; margin-right: 4px;">download</span> Download
</a>
</div>
</div>
} @else {
<div class="quiz-results-grid">
<div class="quiz-result-card" style="border: 1px dashed var(--border-color); background: var(--bg-body);">
<div class="qr-title">Coding Challenge</div>
<div class="qr-pending">Not Submitted</div>
</div>
</div>
}
</div>
<!-- Evaluations -->
<div class="detail-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 class="detail-section-title" style="margin: 0;">Evaluations</h3>
</div>
@if (getVisibleEvaluations(selectedInterview().evaluations).length > 0) {
<div class="eval-list">
@for (ev of getVisibleEvaluations(selectedInterview().evaluations); track ev._id) {
<div class="eval-card">
<div class="eval-header">
<div class="eval-evaluator">
<strong>{{ ev.evaluatorId?.name }}</strong>
<span class="eval-role badge badge-muted">{{ ev.evaluatorRole | uppercase }}</span>
</div>
<span class="badge" [ngClass]="getDecisionClass(ev.recommendation)">{{ formatDecision(ev.recommendation) }}</span>
</div>
@if (ev.comments) {
<p class="eval-comments">"{{ ev.comments }}"</p>
}
<span class="eval-date">{{ ev.date | date:'medium' }}</span>
</div>
}
</div>
} @else {
<p class="text-muted">No evaluations yet</p>
}
<!-- Add evaluation form -->
@if (!hasUserEvaluated() && selectedInterview().status !== 'completed') {
<div class="eval-form">
<h4>Add Your Evaluation</h4>
<div class="form-group">
<label class="form-label">Comments</label>
<textarea class="form-input form-textarea" [(ngModel)]="evalComment" placeholder="Enter your comments..." rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Recommendation *</label>
<select class="form-input" [(ngModel)]="evalRecommendation">
<option value="">Select recommendation</option>
<option value="offer">Offer / Hire as Intern</option>
<option value="on_hold">On Hold</option>
<option value="rejected">Rejected</option>
<option value="2nd_round">2nd Round</option>
</select>
</div>
<button class="btn btn-primary" (click)="submitEvaluation()" [disabled]="!evalRecommendation() || isSubmitting()">
Submit Evaluation
</button>
</div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeDetail()">Close</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedInterview()) {
<div class="print-container">
<div class="print-header">
<div class="print-header-left">
<h2 style="margin: 0; font-size: 20px;">Intern Interview Evaluation Form</h2>
</div>
<div class="print-header-right" style="text-align: right;">
<span style="color: #0078d4; font-weight: bold; font-size: 24px;">IDEAL</span><br>
<span style="font-size: 10px; color: #555;">TECH LABS</span>
</div>
</div>
<table class="print-table" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Candidate Name:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedInterview().candidateId?.name }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Date of Interview:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedInterview().dateOfInterview | date:'mediumDate' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Position:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().position }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Source</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().source || '—' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Tech Stack:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().techStack || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Interviewer(s):</td>
<td style="border: 1px solid #000; padding: 8px;">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[0]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[0]?.completed ? selectedInterview().quizzes[0].score + '/' + selectedInterview().quizzes[0].totalMarks : 'Not Taken' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[1]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[1]?.completed ? selectedInterview().quizzes[1].score + '/' + selectedInterview().quizzes[1].totalMarks : 'Not Taken' }}</td>
</tr>
</table>
<!-- Render evaluations -->
@for (roleObj of [{id:'interviewer', title:'Interviewer'}, {id:'pm', title:'Project Manager'}, {id:'hr', title:'HR'}]; track roleObj.id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ roleObj.title }}'s Comments ({{ getEvaluationByRole(roleObj.id)?.evaluatorId?.name || '' }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ getEvaluationByRole(roleObj.id)?.comments || '' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (getEvaluationByRole(roleObj.id)?.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + getEvaluationByRole(roleObj.id).evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ getEvaluationByRole(roleObj.id)?.date ? (getEvaluationByRole(roleObj.id).date | date:'shortDate') : '' }}</span>
</div>
</div>
</div>
}
</div>
}
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { QuizService } from '../../../services/quiz.service';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-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)
});
}
}
/* ═══════════════════════════════════════════════════════
PAGE LAYOUT
═══════════════════════════════════════════════════════ */
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1200px; margin: 0; }
.page-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px;
}
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
/* Stats */
.stats-row { display: flex; gap: 16px; margin-bottom: 24px; }
.mini-stat { flex: 1; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px 20px; text-align: center; }
.mini-stat-value { display: block; font-size: 28px; font-weight: 700; color: var(--text-primary); }
.mini-stat-value.orange { color: #f59e0b; }
.mini-stat-value.blue { color: #3b82f6; }
.mini-stat-value.green { color: #22c55e; }
.mini-stat-value.red { color: #ef4444; }
.mini-stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
/* Filter */
.filter-bar { margin-bottom: 20px; }
.filter-select {
padding: 8px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
/* ═══════════════════════════════════════════════════════
INTERVIEW CARDS
═══════════════════════════════════════════════════════ */
.interview-list { display: flex; flex-direction: column; gap: 16px; }
.interview-card {
cursor: pointer; transition: all 0.2s; border: 1px solid var(--border-color);
border-radius: 16px; background: var(--bg-card);
}
.interview-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 20px rgba(102,126,234,0.12); transform: translateY(-1px); }
.iv-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.iv-group-info { display: flex; align-items: center; gap: 14px; }
.iv-group-avatar {
width: 48px; height: 48px; border-radius: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.iv-group-avatar .material-symbols-rounded { color: #fff; font-size: 26px; }
.iv-name { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 0; }
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.iv-badges { display: flex; gap: 8px; flex-wrap: wrap; }
.iv-card-meta {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color);
}
.meta-item { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Candidate pills row */
.candidate-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.candidate-pill {
display: flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px;
border-radius: 20px; background: var(--bg-hover); border: 1px solid var(--border-color);
transition: all 0.2s;
}
.candidate-pill:hover { border-color: rgba(102,126,234,0.4); transform: translateY(-1px); }
.cp-avatar {
width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 11px; font-weight: 700; color: #fff; background: #667eea;
}
.cp-name { font-size: 12px; font-weight: 500; color: var(--text-primary); }
.candidate-pill.badge-warning .cp-avatar { background: #f59e0b; }
.candidate-pill.badge-info .cp-avatar { background: #3b82f6; }
.candidate-pill.badge-success .cp-avatar { background: #22c55e; }
.candidate-pill.badge-purple .cp-avatar { background: #a855f7; }
/* 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); }
}
/* ⚠ badge on chip (kept for modal usage) */
.chip-alert { font-size: 11px; margin-right: 3px; }
/* Evaluation pending badge inside candidate row name */
.eval-pending-badge {
display: inline-flex; align-items: center; font-size: 11px; font-weight: 600;
padding: 2px 7px; border-radius: 10px; margin-left: 8px;
background: rgba(245,158,11,0.15); color: #f59e0b;
}
.eval-pending-badge.warn { background: rgba(102,126,234,0.1); color: #667eea; }
/* Clickable candidate left section (button reset) */
.cr-clickable {
display: flex; align-items: center; gap: 12px;
background: none; border: none; text-align: left;
padding: 6px 10px; border-radius: 10px; transition: background 0.15s;
min-width: 160px; flex-shrink: 0;
}
/* Hint text at end of bottom row */
.cr-open-hint { font-size: 11px; color: var(--text-muted); margin-left: auto; font-style: italic; }
/* ─── iv-card-bottom ─────────────────────────────────────── */
.iv-card-bottom { display: flex; justify-content: flex-start; margin-top: 10px; }
.status-summary { font-size: 12px; color: var(--text-muted); font-style: italic; }
/* ═══════════════════════════════════════════════════════
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; }
.badge { padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.badge-warning { background: rgba(245,158,11,0.1); color: #f59e0b; }
.badge-info { background: rgba(59,130,246,0.1); color: #3b82f6; }
.badge-purple { background: rgba(168,85,247,0.1); color: #a855f7; }
.badge-success { background: rgba(34,197,94,0.1); color: #22c55e; }
.badge-danger { background: rgba(239,68,68,0.1); color: #ef4444; }
.badge-muted { background: var(--bg-hover); color: var(--text-muted); }
.badge-group { background: linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.15)); color: #667eea; }
/* ═══════════════════════════════════════════════════════
EMPTY / LOADING
═══════════════════════════════════════════════════════ */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px; gap: 16px; color: var(--text-muted); }
.empty-state { text-align: center; padding: 80px; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 64px; display: block; margin-bottom: 16px; opacity: 0.35; }
.empty-state h3 { color: var(--text-primary); font-size: 18px; margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
/* ═══════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(5px);
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: 18px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3); border: 1px solid var(--border-color);
width: 92%; max-height: 88vh; overflow-y: auto;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-xl { max-width: 860px; }
.modal-header {
padding: 22px 26px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; background: var(--bg-card); z-index: 2; border-radius: 18px 18px 0 0;
}
.modal-header h2 { font-size: 18px; font-weight: 700; margin: 0; }
.modal-body { padding: 26px; display: flex; flex-direction: column; gap: 8px; }
.modal-footer {
padding: 16px 26px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px;
background: var(--bg-hover); position: sticky; bottom: 0; border-radius: 0 0 18px 18px;
}
/* Success banner */
.success-banner {
margin: 24px; padding: 20px 24px; border-radius: 14px;
background: linear-gradient(135deg, rgba(34,197,94,.12), rgba(16,185,129,.08));
border: 1px solid rgba(34,197,94,.3); color: #22c55e;
display: flex; align-items: center; gap: 12px; font-size: 15px; font-weight: 500;
}
.success-banner .material-symbols-rounded { font-size: 28px; }
.success-banner .btn { margin-left: auto; }
/* ═══════════════════════════════════════════════════════
FORM ELEMENTS
═══════════════════════════════════════════════════════ */
.section-title {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 700; color: var(--text-primary);
text-transform: uppercase; letter-spacing: 0.8px;
padding: 10px 0 6px; border-bottom: 1px solid var(--border-color); margin-bottom: 14px; margin-top: 10px;
}
.section-title .material-symbols-rounded { font-size: 18px; color: var(--accent-primary); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 14px; }
.form-row.three-col { grid-template-columns: 1fr 1fr 1fr; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px;
font-family: inherit; transition: all 0.2s; width: 100%; box-sizing: border-box;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.12); }
.field-hint {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; color: #22c55e; margin-top: 2px;
}
.field-hint .material-symbols-rounded { font-size: 14px; }
/* Checklist box */
.checklist-box {
border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px;
max-height: 130px; overflow-y: auto; background: var(--bg-input);
display: flex; flex-direction: column; gap: 6px;
}
.check-item {
display: flex; align-items: center; gap: 8px; font-size: 13px;
color: var(--text-secondary); cursor: pointer; padding: 2px 0;
}
.check-item input[type="checkbox"] { accent-color: var(--accent-primary); }
.check-item:hover { color: var(--text-primary); }
.text-muted { color: var(--text-muted); font-size: 13px; }
/* ═══════════════════════════════════════════════════════
QUIZ CONFIGURATION
═══════════════════════════════════════════════════════ */
.quiz-empty-hint {
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 32px; border: 2px dashed var(--border-color); border-radius: 14px;
background: var(--bg-hover); color: var(--text-muted); margin-bottom: 8px;
}
.quiz-empty-hint .material-symbols-rounded { font-size: 40px; opacity: 0.4; }
.quiz-empty-hint p { margin: 0; font-size: 14px; }
.quiz-setup-prompt {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 14px 18px; border-radius: 10px;
background: rgba(102,126,234,0.06); border: 1px solid rgba(102,126,234,0.2);
margin-bottom: 16px;
}
.quiz-setup-prompt .material-symbols-rounded { font-size: 20px; color: var(--accent-primary); }
.quiz-setup-prompt span { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.sets-count-input {
width: 72px; padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px;
text-align: center; font-family: inherit;
}
.sets-count-input:focus { outline: none; border-color: var(--accent-primary); }
/* Quiz Set Block */
.quiz-set-block {
border: 1px solid var(--border-color); border-radius: 14px;
padding: 18px 20px; margin-bottom: 14px;
background: var(--bg-hover); display: flex; flex-direction: column; gap: 10px;
transition: border-color 0.2s;
}
.quiz-set-block:hover { border-color: rgba(102,126,234,0.4); }
.quiz-set-header { display: flex; align-items: center; justify-content: space-between; }
.quiz-set-title { display: flex; align-items: center; gap: 10px; }
.set-badge {
background: linear-gradient(135deg, #667eea, #764ba2); color: #fff;
font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 20px;
}
.set-note { font-size: 12px; color: var(--text-muted); }
/* Quiz entry row */
.quiz-entry-row {
display: flex; align-items: center; gap: 10px;
}
.quiz-entry-row .form-input { flex: 1; }
/* Assignment mode */
.assignment-mode-row {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding-top: 8px; border-top: 1px solid var(--border-color);
}
.mode-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.mode-toggle { display: flex; gap: 8px; }
.mode-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border-color); background: var(--bg-input);
color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
}
.mode-btn .material-symbols-rounded { font-size: 16px; }
.mode-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.mode-btn.active { border-color: #667eea; background: rgba(102,126,234,0.12); color: #667eea; font-weight: 700; }
/* Direct assignment table */
.direct-assignment-table {
border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden;
margin-top: 4px;
}
.da-header {
display: grid; grid-template-columns: 1fr 1.5fr;
padding: 10px 14px; background: rgba(102,126,234,0.08);
font-size: 12px; font-weight: 700; color: var(--text-primary); text-transform: uppercase; letter-spacing: 0.5px;
}
.da-row {
display: grid; grid-template-columns: 1fr 1.5fr; align-items: center;
padding: 10px 14px; border-top: 1px solid var(--border-color);
gap: 12px; background: var(--bg-card); transition: background 0.15s;
}
.da-row:hover { background: var(--bg-hover); }
.da-candidate { display: flex; align-items: center; gap: 10px; }
.da-avatar {
width: 30px; height: 30px; border-radius: 8px; font-size: 13px; font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2); color: #fff;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.da-row .form-input { padding: 7px 10px; font-size: 13px; }
/* Ghost btn */
.btn-ghost {
background: none; border: 1px dashed var(--border-color); color: var(--text-muted);
padding: 7px 14px; border-radius: 8px; font-size: 13px; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: all 0.2s;
font-family: inherit;
}
.btn-ghost:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
.btn-ghost .material-symbols-rounded { font-size: 16px; }
.icon-btn.danger { color: #ef4444; }
.icon-btn.danger:hover { background: rgba(239,68,68,0.08); }
/* ═══════════════════════════════════════════════════════
DETAIL MODAL
═══════════════════════════════════════════════════════ */
.detail-body { display: flex; flex-direction: column; gap: 24px; }
.detail-section { padding-bottom: 16px; border-bottom: 1px solid var(--border-color); }
.detail-section:last-child { border-bottom: none; }
.detail-section-title { font-size: 12px; font-weight: 700; color: var(--text-primary); margin: 0 0 16px; text-transform: uppercase; letter-spacing: 0.8px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-item { display: flex; flex-direction: column; gap: 3px; }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
/* Candidate detail list */
.candidate-detail-list { display: flex; flex-direction: column; gap: 12px; }
.candidate-row {
display: flex; flex-direction: column; gap: 12px; padding: 16px;
border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-hover);
transition: border-color 0.2s;
}
.candidate-row:hover { border-color: rgba(102,126,234,0.35); }
/* Top row: avatar+name on left, progress stepper fills the rest */
.candidate-row-top { display: flex; align-items: center; gap: 16px; }
.candidate-row-left { display: flex; align-items: center; gap: 12px; min-width: 160px; flex-shrink: 0; }
.iv-avatar { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg,#667eea,#764ba2); color:#fff; font-weight:700; font-size:16px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.iv-avatar.small { width: 36px; height: 36px; font-size: 15px; border-radius: 9px; }
.cr-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.cr-email { font-size: 12px; color: var(--text-muted); }
.candidate-row-mid { flex: 1; }
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.candidate-row-bottom {
display: flex; flex-wrap: nowrap; align-items: center; gap: 8px;
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 { display: flex; align-items: center; }
.progress-steps.mini .step-label { font-size: 10px; }
.progress-steps.mini .step-dot { width: 8px; height: 8px; }
.step { display: flex; align-items: center; gap: 4px; }
.step-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--border-color); transition: all 0.3s; }
.step.done .step-dot { background: #22c55e; }
.step.active .step-dot { background: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.2); }
.step-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.step.done .step-label { color: #22c55e; }
.step.active .step-label { color: #667eea; font-weight: 600; }
.step-line { flex: 1; height: 2px; background: var(--border-color); margin: 0 6px; transition: background 0.3s; min-width: 16px; }
.step-line.done { background: #22c55e; }
/* Quiz score chips */
.quiz-scores-inline { display: flex; flex-wrap: wrap; gap: 6px; }
.quiz-score-chip {
font-size: 11px; padding: 3px 10px; border-radius: 20px;
background: var(--bg-card); border: 1px solid var(--border-color);
color: var(--text-muted); white-space: nowrap;
}
.quiz-score-chip.completed { color: #22c55e; border-color: rgba(34,197,94,0.3); background: rgba(34,197,94,0.07); }
/* Mini decision buttons */
.mini-decision-btns { display: flex; gap: 6px; }
.mini-btn {
padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
border: none; cursor: pointer; transition: opacity 0.2s; font-family: inherit;
}
.mini-btn.success { background: rgba(34,197,94,0.15); color: #22c55e; }
.mini-btn.success:hover { background: #22c55e; color: #fff; }
.mini-btn.danger { background: rgba(239,68,68,0.12); color: #ef4444; }
.mini-btn.danger:hover { background: #ef4444; color: #fff; }
/* ═══════════════════════════════════════════════════════
BUTTONS (shared)
═══════════════════════════════════════════════════════ */
.btn-success { background: #22c55e; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(24px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ═══════════════════════════════════════════════════════
EVALUATION PANEL (member detail modal)
Mirrors individual-interview styles
═══════════════════════════════════════════════════════ */
.quiz-results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.quiz-result-card {
padding: 14px 16px; border-radius: 12px; border: 1px solid var(--border-color);
background: var(--bg-input);
}
.qr-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.qr-score { font-size: 22px; font-weight: 700; color: #22c55e; }
.qr-pending { font-size: 13px; color: var(--text-muted); font-style: italic; }
.eval-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
.eval-card {
padding: 14px 16px; border-radius: 12px; border: 1px solid var(--border-color);
background: var(--bg-input);
}
.eval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.eval-evaluator { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.eval-role { font-size: 10px !important; padding: 2px 6px !important; }
.eval-comments { font-size: 13px; color: var(--text-secondary); font-style: italic; margin: 6px 0 8px; }
.eval-date { font-size: 11px; color: var(--text-muted); }
.eval-form {
margin-top: 16px; padding: 18px 20px; border-radius: 14px;
border: 1px dashed rgba(102,126,234,0.35);
background: rgba(102,126,234,0.04);
display: flex; flex-direction: column; gap: 12px;
}
.eval-form h4 { font-size: 14px; font-weight: 700; color: var(--text-primary); margin: 0; }
.form-textarea { resize: vertical; min-height: 80px; }
.decision-section { border-bottom: none; }
.decision-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
.btn-success { background: #22c55e; color: #fff; border: none; }
.btn-warning { background: #f59e0b; color: #fff; border: none; }
/* ═══════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════ */
@media (max-width: 768px) {
.page-container { padding: 20px; }
.stats-row { flex-wrap: wrap; }
.mini-stat { min-width: 100px; }
.form-row, .form-row.three-col { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr; }
.candidate-row { flex-direction: column; }
.candidate-row-right { align-items: flex-start; }
.da-header, .da-row { grid-template-columns: 1fr; }
}
/* ═══════════════════════════════════════════════════════
PRINT STYLES FOR EVALUATION PDF
═══════════════════════════════════════════════════════ */
.print-container {
display: none;
}
@media print {
body * {
visibility: hidden;
}
.print-container, .print-container * {
visibility: visible;
}
.print-container {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
background: white;
color: black;
font-family: Arial, sans-serif;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #0078d4;
padding-bottom: 10px;
margin-bottom: 20px;
}
}
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<!-- Page Header -->
<div class="page-header">
<div>
<h1>Group Interviews</h1>
<p class="page-subtitle">Create and manage batch interview sessions for candidate groups</p>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ stats().total }}</span>
<span class="mini-stat-label">Total</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value orange">{{ stats().pending }}</span>
<span class="mini-stat-label">In Progress</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value blue">{{ stats().completed }}</span>
<span class="mini-stat-label">Completed</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value green">{{ stats().accepted }}</span>
<span class="mini-stat-label">Accepted</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value red">{{ stats().rejected }}</span>
<span class="mini-stat-label">Rejected</span>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<select class="filter-select" [(ngModel)]="filterStatus" (ngModelChange)="onFilterChange()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="quiz_phase">Quiz Phase</option>
<option value="coding_phase">Coding Phase</option>
<option value="evaluation">Evaluation</option>
<option value="completed">Completed</option>
</select>
</div>
<!-- Interview Group List -->
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading interviews...</p></div>
} @else if (groupedList().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">groups</span>
<h3>No group interviews found</h3>
<p>Create your first group interview session to get started</p>
</div>
} @else {
<div class="interview-list">
@for (g of groupedList(); track g.groupId) {
<div class="interview-card card card-padding" (click)="openDetail(g)">
<!-- Top row: group info + badges + dropdown toggle -->
<div class="iv-card-top">
<div class="iv-group-info">
<div class="iv-group-avatar">
<span class="material-symbols-rounded">groups</span>
</div>
<div>
<h3 class="iv-name">{{ g.groupId }}</h3>
<p class="iv-email">{{ g.members.length }} candidate{{ g.members.length !== 1 ? 's' : '' }} · {{ g.position }}</p>
</div>
</div>
<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>
<!-- Meta row -->
<div class="iv-card-meta">
<span class="meta-item"><span class="material-symbols-rounded">work</span> {{ g.position }}</span>
@if (g.techStack) {
<span class="meta-item"><span class="material-symbols-rounded">code</span> {{ g.techStack }}</span>
}
<span class="meta-item"><span class="material-symbols-rounded">calendar_today</span> {{ g.dateOfInterview | date:'mediumDate' }}</span>
@if (g.assignedInterviewers?.length > 0) {
<span class="meta-item"><span class="material-symbols-rounded">person</span> {{ g.assignedInterviewers[0]?.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>
</div>
}
</div>
}
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
DETAIL MODAL — All candidates in a group session
═══════════════════════════════════════════════════════════ -->
@if (showDetailModal() && selectedGroup()) {
<div class="modal-overlay" (click)="closeDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div>
<h2>{{ selectedGroup().groupId }}</h2>
<span style="font-size:13px; color:var(--text-muted)">
{{ selectedGroup().position }} · {{ selectedGroup().members.length }} candidates
</span>
</div>
<button class="icon-btn" (click)="closeDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Session meta -->
<div class="detail-section">
<h3 class="detail-section-title">Session Info</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedGroup().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedGroup().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span class="detail-value">{{ selectedGroup().source || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Interview Date</span>
<span class="detail-value">{{ selectedGroup().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Candidate rows -->
<div class="detail-section">
<h3 class="detail-section-title">Candidates</h3>
<div class="candidate-detail-list">
@for (m of selectedGroup().members; track m._id) {
<div class="candidate-row">
<!-- Top row: clickable avatar+name on the left, progress bar on the right -->
<div class="candidate-row-top">
<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 }}
</div>
<div class="cr-email">{{ m.candidateId?.email }}</div>
</div>
</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)">
<span class="step-dot"></span><span class="step-label">Created</span>
</div>
<div class="step-line" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"></div>
<div class="step" [class.active]="m.status==='quiz_phase'" [class.done]="['coding_phase','evaluation','completed'].includes(m.status)">
<span class="step-dot"></span><span class="step-label">Quiz</span>
</div>
<div class="step-line" [class.done]="['coding_phase','evaluation','completed'].includes(m.status)"></div>
<div class="step" [class.active]="m.status==='coding_phase'" [class.done]="['evaluation','completed'].includes(m.status)">
<span class="step-dot"></span><span class="step-label">Coding</span>
</div>
<div class="step-line" [class.done]="['evaluation','completed'].includes(m.status)"></div>
<div class="step" [class.active]="m.status==='evaluation'" [class.done]="m.status==='completed'">
<span class="step-dot"></span><span class="step-label">Evaluate</span>
</div>
<div class="step-line" [class.done]="m.status==='completed'"></div>
<div class="step" [class.active]="m.status==='completed'" [class.done]="m.status==='completed'">
<span class="step-dot"></span><span class="step-label">Done</span>
</div>
</div>
</div>
</div>
<!-- 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>
}
</div>
</div>
}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeDetail()">Close</button>
</div>
</div>
</div>
}
<!-- ═══════════════════════════════════════════════════════════
MEMBER DETAIL MODAL — full evaluation panel for one candidate
═══════════════════════════════════════════════════════════ -->
@if (showMemberDetailModal() && selectedMember()) {
<div class="modal-overlay" (click)="closeMemberDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="modal-header">
<div>
<h2>{{ selectedMember().candidateId?.name }}</h2>
<span class="badge" [ngClass]="getStatusClass(selectedMember().status)">{{ formatStatus(selectedMember().status) }}</span>
@if (selectedMember().finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(selectedMember().finalDecision)" style="margin-left:6px">
{{ formatDecision(selectedMember().finalDecision) }}
</span>
}
</div>
<button class="icon-btn" (click)="closeMemberDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Candidate Info -->
<div class="detail-section">
<h3 class="detail-section-title">Candidate Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Name</span>
<span class="detail-value">{{ selectedMember().candidateId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{ selectedMember().candidateId?.email }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedMember().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedMember().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date of Interview</span>
<span class="detail-value">{{ selectedMember().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedMember().quizzes?.length > 0) {
<div class="detail-section">
<h3 class="detail-section-title">Quiz Results</h3>
<div class="quiz-results-grid">
@for (q of selectedMember().quizzes; track q.quizId) {
<div class="quiz-result-card">
<div class="qr-title">{{ q.title }}</div>
@if (q.completed) {
<div class="qr-score">{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)</div>
} @else {
<div class="qr-pending">Not Taken</div>
}
</div>
}
</div>
</div>
}
<!-- Evaluations -->
<div class="detail-section">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h3 class="detail-section-title" style="margin:0;">Evaluations</h3>
<div style="display: flex; gap: 8px; align-items: center;">
@if (allMemberEvaluationsDone()) {
<span class="badge badge-success">All evaluations done</span>
<button class="btn btn-primary btn-sm" (click)="downloadEvaluationPdf()">
@if (isPdfGenerating()) { <span class="spinner spinner-sm"></span> Generating... } @else { <span class="material-symbols-rounded" style="font-size: 16px; margin-right: 4px;">download</span> Download PDF }
</button>
} @else {
<span class="badge badge-warning">Evaluations pending</span>
}
</div>
</div>
<!-- Evaluation list -->
@if (getVisibleEvaluations(selectedMember().evaluations).length > 0) {
<div class="eval-list">
@for (ev of getVisibleEvaluations(selectedMember().evaluations); track ev._id) {
<div class="eval-card">
<div class="eval-header">
<div class="eval-evaluator">
<strong>{{ ev.evaluatorId?.name }}</strong>
<span class="eval-role badge badge-muted">{{ ev.evaluatorRole | uppercase }}</span>
</div>
<span class="badge" [ngClass]="getDecisionClass(ev.recommendation)">{{ formatDecision(ev.recommendation) }}</span>
</div>
@if (ev.comments) {
<p class="eval-comments">"{{ ev.comments }}"</p>
}
<span class="eval-date">{{ ev.date | date:'medium' }}</span>
</div>
}
</div>
} @else {
<p class="text-muted">No evaluations yet</p>
}
<!-- Add evaluation form (HR / PM / Interviewer, not if already submitted or completed) -->
@if (!hasMemberEvaluated() && selectedMember().status !== 'completed') {
<div class="eval-form">
<h4>Add Your Evaluation</h4>
<div class="form-group">
<label class="form-label">Comments</label>
<textarea class="form-input form-textarea" [(ngModel)]="memberEvalComment"
placeholder="Enter your comments..." rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Recommendation *</label>
<select class="form-input" [(ngModel)]="memberEvalRecommendation">
<option value="">Select recommendation</option>
<option value="offer">Offer / Hire as Intern</option>
<option value="on_hold">On Hold</option>
<option value="rejected">Rejected</option>
<option value="2nd_round">2nd Round</option>
</select>
</div>
<button class="btn btn-primary" (click)="submitMemberEvaluation()"
[disabled]="!memberEvalRecommendation || isMemberSubmitting()">
@if (isMemberSubmitting()) { <span class="spinner spinner-sm"></span> Submitting... }
@else { Submit Evaluation }
</button>
</div>
}
</div>
<!-- Final Decision — Admin and HR, only when all evaluations done -->
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeMemberDetail()">Close</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedMember()) {
<div class="print-container">
<div class="print-header">
<div class="print-header-left">
<h2 style="margin: 0; font-size: 20px;">Intern Interview Evaluation Form</h2>
</div>
<div class="print-header-right" style="text-align: right;">
<span style="color: #0078d4; font-weight: bold; font-size: 24px;">IDEAL</span><br>
<span style="font-size: 10px; color: #555;">TECH LABS</span>
</div>
</div>
<table class="print-table" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Candidate Name:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedMember().candidateId?.name }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Date of Interview:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedMember().dateOfInterview | date:'mediumDate' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Position:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().position }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Source</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().source || '—' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Tech Stack:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().techStack || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Interviewer(s):</td>
<td style="border: 1px solid #000; padding: 8px;">
@if (selectedMember().assignedInterviewers?.length) {
@for (i of selectedMember().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedMember().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[0]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[0]?.completed ? selectedMember().quizzes[0].score + '/' + selectedMember().quizzes[0].totalMarks : 'Not Taken' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[1]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedMember().quizzes?.[1]?.completed ? selectedMember().quizzes[1].score + '/' + selectedMember().quizzes[1].totalMarks : 'Not Taken' }}</td>
</tr>
</table>
<!-- Render evaluations -->
@for (ev of selectedMember().evaluations; track ev._id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ ev.comments || 'No comments provided.' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (ev.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + ev.evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ ev.date | date:'shortDate' }}</span>
</div>
</div>
</div>
}
</div>
}
import { Component, OnInit, signal, 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);
}
}
.page-container { padding: 32px 40px; }
.content-wrapper { max-width: 1200px; margin: 0; }
.page-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px;
}
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
/* Stats Row */
.stats-row {
display: flex; gap: 16px; margin-bottom: 24px;
}
.mini-stat {
flex: 1; background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: 12px; padding: 16px 20px; text-align: center;
}
.mini-stat-value { display: block; font-size: 28px; font-weight: 700; color: var(--text-primary); }
.mini-stat-value.orange { color: #f59e0b; }
.mini-stat-value.blue { color: #3b82f6; }
.mini-stat-value.green { color: #22c55e; }
.mini-stat-value.red { color: #ef4444; }
.mini-stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
/* Filter */
.filter-bar { margin-bottom: 20px; }
.filter-select {
padding: 8px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
}
/* Interview Cards */
.interview-list { display: flex; flex-direction: column; gap: 16px; }
.interview-card {
cursor: pointer; transition: all 0.2s; border: 1px solid var(--border-color);
border-radius: 16px; background: var(--bg-card);
}
.interview-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 16px rgba(102,126,234,0.1); transform: translateY(-1px); }
.iv-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.iv-candidate { display: flex; align-items: center; gap: 14px; }
.iv-avatar {
width: 44px; height: 44px; border-radius: 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px; flex-shrink: 0;
}
.iv-name { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; }
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.iv-badges { display: flex; gap: 8px; }
.iv-card-meta {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px; padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
}
.meta-item {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary);
}
.meta-item .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Progress Steps */
.progress-steps { display: flex; align-items: center; }
.step { display: flex; align-items: center; gap: 6px; }
.step-dot {
width: 10px; height: 10px; border-radius: 50%; background: var(--border-color); transition: all 0.3s;
}
.step.done .step-dot { background: #22c55e; }
.step.active .step-dot { background: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.2); }
.step-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.step.done .step-label { color: #22c55e; }
.step.active .step-label { color: #667eea; font-weight: 600; }
.step-line {
flex: 1; height: 2px; background: var(--border-color); margin: 0 8px; transition: background 0.3s;
}
.step-line.done { background: #22c55e; }
/* Badges */
.badge {
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-warning { background: rgba(245,158,11,0.1); color: #f59e0b; }
.badge-info { background: rgba(59,130,246,0.1); color: #3b82f6; }
.badge-purple { background: rgba(168,85,247,0.1); color: #a855f7; }
.badge-success { background: rgba(34,197,94,0.1); color: #22c55e; }
.badge-danger { background: rgba(239,68,68,0.1); color: #ef4444; }
.badge-muted { background: var(--bg-hover); color: var(--text-muted); }
/* Empty / Loading */
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px; gap: 16px; color: var(--text-muted); }
.empty-state { text-align: center; padding: 80px; color: var(--text-muted); }
.empty-state .material-symbols-rounded { font-size: 56px; display: block; margin-bottom: 16px; opacity: 0.4; }
.empty-state h3 { color: var(--text-primary); font-size: 18px; margin: 0 0 8px; }
.empty-state p { font-size: 14px; margin: 0; }
/* Modal */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); backdrop-filter: blur(4px);
z-index: 1000; display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: var(--bg-card); border-radius: 16px;
box-shadow: 0 10px 50px rgba(0,0,0,0.25); border: 1px solid var(--border-color);
width: 90%; max-height: 85vh; overflow-y: auto;
animation: slideUp 0.3s cubic-bezier(0.16,1,0.3,1);
}
.modal-lg { max-width: 640px; }
.modal-xl { max-width: 800px; }
.modal-header {
padding: 20px 24px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; background: var(--bg-card); z-index: 2; border-radius: 16px 16px 0 0;
}
.modal-header h2 { font-size: 18px; font-weight: 600; margin: 0 12px 0 0; display: inline; }
.modal-body { padding: 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px;
background: var(--bg-hover); position: sticky; bottom: 0; border-radius: 0 0 16px 16px;
}
/* Form */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-input); color: var(--text-primary); font-size: 14px; font-family: inherit;
transition: all 0.2s; width: 100%; box-sizing: border-box;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.form-textarea { resize: vertical; min-height: 80px; }
/* Quiz Selector */
.quiz-select-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.quiz-select-item {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px;
border: 1px solid var(--border-color); border-radius: 8px;
cursor: pointer; font-size: 13px; color: var(--text-secondary); transition: all 0.2s;
}
.quiz-select-item .material-symbols-rounded { font-size: 18px; }
.quiz-select-item.selected { border-color: #667eea; background: rgba(102,126,234,0.08); color: #667eea; }
.quiz-select-item:hover { border-color: var(--accent-primary); }
/* Detail */
.detail-body { display: flex; flex-direction: column; gap: 24px; }
.detail-section { padding-bottom: 16px; border-bottom: 1px solid var(--border-color); }
.detail-section:last-child { border-bottom: none; }
.detail-section-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; text-transform: uppercase; letter-spacing: 0.5px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-item { display: flex; flex-direction: column; gap: 2px; }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 14px; color: var(--text-primary); font-weight: 500; }
/* Quiz Results */
.quiz-results-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.quiz-result-card {
padding: 14px; border: 1px solid var(--border-color); border-radius: 10px;
background: var(--bg-hover);
}
.qr-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.qr-score { font-size: 14px; color: #22c55e; font-weight: 700; }
.qr-pending { font-size: 13px; color: var(--text-muted); font-style: italic; }
/* Evaluations */
.eval-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.eval-card {
padding: 16px; border: 1px solid var(--border-color); border-radius: 10px;
background: var(--bg-hover);
}
.eval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.eval-evaluator { display: flex; align-items: center; gap: 8px; }
.eval-evaluator strong { font-size: 14px; color: var(--text-primary); }
.eval-role { font-size: 10px; }
.eval-comments { font-size: 14px; color: var(--text-secondary); margin: 8px 0; font-style: italic; line-height: 1.5; }
.eval-date { font-size: 11px; color: var(--text-muted); }
.eval-form {
margin-top: 16px; padding: 20px; border: 1px dashed rgba(102,126,234,0.3);
border-radius: 12px; background: rgba(102,126,234,0.03);
}
.eval-form h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0 0 16px; }
/* Decision */
.decision-section { padding: 16px; background: rgba(102,126,234,0.03); border-radius: 12px; border: 1px dashed rgba(102,126,234,0.2); }
.decision-buttons { display: flex; gap: 12px; flex-wrap: wrap; }
.btn-success { background: #22c55e; color: #fff; }
.btn-success:hover { background: #16a34a; }
.btn-warning { background: #f59e0b; color: #fff; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.text-muted { color: var(--text-muted); font-size: 13px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (max-width: 768px) {
.page-container { padding: 20px; }
.stats-row { flex-wrap: wrap; }
.mini-stat { min-width: 120px; }
.form-row { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr; }
}
/* Print Styles */
.print-container {
display: none;
}
@media print {
body * {
visibility: hidden;
}
.print-container, .print-container * {
visibility: visible;
}
.print-container {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
background: white;
color: black;
font-family: Arial, sans-serif;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #0078d4;
padding-bottom: 10px;
margin-bottom: 20px;
}
}
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<div class="page-header">
<div>
<h1>Individual Interviews</h1>
<p class="page-subtitle">View and evaluate your assigned candidates</p>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ stats().total }}</span>
<span class="mini-stat-label">Total</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value orange">{{ stats().pending }}</span>
<span class="mini-stat-label">In Progress</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value blue">{{ stats().completed }}</span>
<span class="mini-stat-label">Completed</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value green">{{ stats().accepted }}</span>
<span class="mini-stat-label">Accepted</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value red">{{ stats().rejected }}</span>
<span class="mini-stat-label">Rejected</span>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<select class="filter-select" [(ngModel)]="filterStatus" (ngModelChange)="onFilterChange()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="quiz_phase">Quiz Phase</option>
<option value="coding_phase">Coding Phase</option>
<option value="evaluation">Evaluation</option>
<option value="completed">Completed</option>
</select>
</div>
<!-- Interview List -->
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading interviews...</p></div>
} @else if (interviews().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">work_off</span>
<h3>No interviews found</h3>
<p>Create your first interview to get started</p>
</div>
} @else {
<div class="interview-list">
@for (iv of interviews(); track iv._id) {
<div class="interview-card card card-padding" (click)="openDetail(iv)">
<div class="iv-card-top">
<div class="iv-candidate">
<div class="iv-avatar">{{ iv.candidateId?.name?.charAt(0)?.toUpperCase() }}</div>
<div>
<h3 class="iv-name">{{ iv.candidateId?.name }}</h3>
<p class="iv-email">{{ iv.candidateId?.email }}</p>
</div>
</div>
<div class="iv-badges">
<span class="badge" [ngClass]="getStatusClass(iv.status)">{{ formatStatus(iv.status) }}</span>
@if (iv.finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(iv.finalDecision)">{{ formatDecision(iv.finalDecision) }}</span>
}
</div>
</div>
<div class="iv-card-meta">
<span class="meta-item"><span class="material-symbols-rounded">work</span> {{ iv.position }}</span>
@if (iv.techStack) {
<span class="meta-item"><span class="material-symbols-rounded">code</span> {{ iv.techStack }}</span>
}
<span class="meta-item"><span class="material-symbols-rounded">person</span> {{ iv.interviewerId?.name }}</span>
<span class="meta-item"><span class="material-symbols-rounded">calendar_today</span> {{ iv.dateOfInterview | date:'mediumDate' }}</span>
</div>
<div class="iv-card-progress">
<div class="progress-steps">
<div class="step" [class.active]="iv.status === 'pending'" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Created</span>
</div>
<div class="step-line" [class.done]="['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'quiz_phase'" [class.done]="['coding_phase','evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Quiz</span>
</div>
<div class="step-line" [class.done]="['coding_phase','evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'coding_phase'" [class.done]="['evaluation','completed'].includes(iv.status)">
<span class="step-dot"></span><span class="step-label">Coding</span>
</div>
<div class="step-line" [class.done]="['evaluation','completed'].includes(iv.status)"></div>
<div class="step" [class.active]="iv.status === 'evaluation'" [class.done]="iv.status === 'completed'">
<span class="step-dot"></span><span class="step-label">Evaluate</span>
</div>
<div class="step-line" [class.done]="iv.status === 'completed'"></div>
<div class="step" [class.active]="iv.status === 'completed'" [class.done]="iv.status === 'completed'">
<span class="step-dot"></span><span class="step-label">Done</span>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- Interview Detail Modal -->
@if (showDetailModal() && selectedInterview()) {
<div class="modal-overlay" (click)="closeDetail()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div>
<h2>Interview Detail</h2>
<span class="badge" [ngClass]="getStatusClass(selectedInterview().status)">{{ formatStatus(selectedInterview().status) }}</span>
@if (selectedInterview().finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(selectedInterview().finalDecision)" style="margin-left:8px">{{ formatDecision(selectedInterview().finalDecision) }}</span>
}
</div>
<button class="icon-btn" (click)="closeDetail()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body detail-body">
<!-- Candidate Info -->
<div class="detail-section">
<h3 class="detail-section-title">Candidate Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Name</span>
<span class="detail-value">{{ selectedInterview().candidateId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{ selectedInterview().candidateId?.email }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Position</span>
<span class="detail-value">{{ selectedInterview().position }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Tech Stack</span>
<span class="detail-value">{{ selectedInterview().techStack || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span class="detail-value">{{ selectedInterview().source || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Interviewer</span>
<span class="detail-value">{{ selectedInterview().interviewerId?.name }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Date of Interview</span>
<span class="detail-value">{{ selectedInterview().dateOfInterview | date:'mediumDate' }}</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedInterview().quizzes?.length > 0) {
<div class="detail-section">
<h3 class="detail-section-title">Quiz Results</h3>
<div class="quiz-results-grid">
@for (q of selectedInterview().quizzes; track q.quizId) {
<div class="quiz-result-card">
<div class="qr-title">{{ q.title }}</div>
@if (q.completed) {
<div class="qr-score">{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)</div>
} @else {
<div class="qr-pending">Not Taken</div>
}
</div>
}
</div>
</div>
}
<!-- Coding Round Submission -->
<div class="detail-section">
<h3 class="detail-section-title">Coding Challenge Submission</h3>
@if (selectedInterview().codingRound?.zipFile) {
<div class="eval-card" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<span class="material-symbols-rounded" style="color: var(--accent-primary); vertical-align: middle; margin-right: 8px; font-size: 24px;">folder_zip</span>
<strong>Submitted on:</strong> {{ selectedInterview().codingRound.submittedAt | date:'medium' }}
</div>
<div style="display: flex; gap: 12px; align-items: center;">
@if (selectedInterview().codingRound.validated) {
<span class="badge badge-success">Validated by {{ selectedInterview().codingRound.validatedBy?.name || 'Reviewer' }}</span>
}
<a [href]="'http://localhost:5000' + selectedInterview().codingRound.zipFile" target="_blank" class="btn btn-primary" style="padding: 6px 12px; font-size: 14px; border-radius: 6px;">
<span class="material-symbols-rounded" style="font-size: 18px; margin-right: 4px;">download</span> Download
</a>
</div>
</div>
} @else {
<div class="quiz-results-grid">
<div class="quiz-result-card" style="border: 1px dashed var(--border-color); background: var(--bg-body);">
<div class="qr-title">Coding Challenge</div>
<div class="qr-pending">Not Submitted</div>
</div>
</div>
}
</div>
<!-- Evaluations -->
<div class="detail-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 class="detail-section-title" style="margin: 0;">Evaluations</h3>
@if (allEvaluationsDone()) {
<button class="btn btn-primary" (click)="downloadEvaluationPdf()">
@if (isPdfGenerating()) { <span class="spinner spinner-sm"></span> Generating... } @else { <span class="material-symbols-rounded">download</span> Download PDF }
</button>
}
</div>
@if (getVisibleEvaluations(selectedInterview().evaluations).length > 0) {
<div class="eval-list">
@for (ev of getVisibleEvaluations(selectedInterview().evaluations); track ev._id) {
<div class="eval-card">
<div class="eval-header">
<div class="eval-evaluator">
<strong>{{ ev.evaluatorId?.name }}</strong>
<span class="eval-role badge badge-muted">{{ ev.evaluatorRole | uppercase }}</span>
</div>
<span class="badge" [ngClass]="getDecisionClass(ev.recommendation)">{{ formatDecision(ev.recommendation) }}</span>
</div>
@if (ev.comments) {
<p class="eval-comments">"{{ ev.comments }}"</p>
}
<span class="eval-date">{{ ev.date | date:'medium' }}</span>
</div>
}
</div>
} @else {
<p class="text-muted">No evaluations yet</p>
}
<!-- Add evaluation form -->
@if (!hasUserEvaluated() && selectedInterview().status !== 'completed') {
<div class="eval-form">
<h4>Add Your Evaluation</h4>
<div class="form-group">
<label class="form-label">Comments</label>
<textarea class="form-input form-textarea" [(ngModel)]="evalComment" placeholder="Enter your comments..." rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">Recommendation *</label>
<select class="form-input" [(ngModel)]="evalRecommendation">
<option value="">Select recommendation</option>
<option value="offer">Offer / Hire as Intern</option>
<option value="on_hold">On Hold</option>
<option value="rejected">Rejected</option>
<option value="2nd_round">2nd Round</option>
</select>
</div>
<button class="btn btn-primary" (click)="submitEvaluation()" [disabled]="!evalRecommendation() || isSubmitting()">
Submit Evaluation
</button>
</div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeDetail()">Close</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedInterview()) {
<div class="print-container">
<div class="print-header">
<div class="print-header-left">
<h2 style="margin: 0; font-size: 20px;">Intern Interview Evaluation Form</h2>
</div>
<div class="print-header-right" style="text-align: right;">
<span style="color: #0078d4; font-weight: bold; font-size: 24px;">IDEAL</span><br>
<span style="font-size: 10px; color: #555;">TECH LABS</span>
</div>
</div>
<table class="print-table" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Candidate Name:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedInterview().candidateId?.name }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;">Date of Interview:</td>
<td style="border: 1px solid #000; padding: 8px; width: 25%;">{{ selectedInterview().dateOfInterview | date:'mediumDate' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Position:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().position }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Source</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().source || '—' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Tech Stack:</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().techStack || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Interviewer(s):</td>
<td style="border: 1px solid #000; padding: 8px;">
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[0]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">General Aptitude Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[0]?.completed ? selectedInterview().quizzes[0].score + '/' + selectedInterview().quizzes[0].totalMarks : 'Not Taken' }}</td>
</tr>
<tr>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test QP Set</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[1]?.title || '—' }}</td>
<td style="border: 1px solid #000; padding: 8px; font-weight: bold;">Technical MCQ Test Score</td>
<td style="border: 1px solid #000; padding: 8px;">{{ selectedInterview().quizzes?.[1]?.completed ? selectedInterview().quizzes[1].score + '/' + selectedInterview().quizzes[1].totalMarks : 'Not Taken' }}</td>
</tr>
</table>
<!-- Render evaluations -->
@for (roleObj of [{id:'interviewer', title:'Interviewer'}, {id:'pm', title:'Project Manager'}, {id:'hr', title:'HR'}]; track roleObj.id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ roleObj.title }}'s Comments ({{ getEvaluationByRole(roleObj.id)?.evaluatorId?.name || '' }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ getEvaluationByRole(roleObj.id)?.comments || '' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (getEvaluationByRole(roleObj.id)?.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + getEvaluationByRole(roleObj.id).evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ getEvaluationByRole(roleObj.id)?.date ? (getEvaluationByRole(roleObj.id).date | date:'shortDate') : '' }}</span>
</div>
</div>
</div>
}
</div>
}
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { QuizService } from '../../../services/quiz.service';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-pm-individual-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css'
})
export class PMIndividualInterviewComponent 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);
}
}
......@@ -49,6 +49,60 @@
.iv-email { font-size: 13px; color: var(--text-muted); margin: 0; }
.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 {
display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 14px;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color);
......@@ -113,6 +167,84 @@
.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; }
/* ═══════════════════════════════════════════════════════
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
═══════════════════════════════════════════════════════ */
......@@ -338,9 +470,12 @@
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.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);
overflow-x: auto; scrollbar-width: none;
}
.candidate-row-bottom::-webkit-scrollbar { display: none; }
.candidate-row-bottom > * { flex-shrink: 0; }
/* Progress steps (mini) */
.progress-steps { display: flex; align-items: center; }
......
......@@ -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,21 +108,41 @@
}
</div>
<!-- Candidate progress chips — click any chip to open the evaluation panel directly -->
<div class="candidate-chips">
<!-- 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) {
<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 }}
<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>
......@@ -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">
<!-- 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>
}
@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>
}
}
<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">
......
......@@ -85,7 +85,7 @@ export class GroupInterviewComponent implements OnInit {
memberEvalRecommendation = '';
isMemberSubmitting = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
constructor(private quizService: QuizService, public authService: AuthService) { }
ngOnInit(): void {
this.loadInterviews();
......@@ -411,4 +411,29 @@ export class GroupInterviewComponent implements OnInit {
next: () => this.loadInterviews()
});
}
// -- 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)
);
}
}
......@@ -457,35 +457,35 @@
</table>
<!-- Render evaluations -->
@for (ev of selectedInterview().evaluations; track ev._id) {
@for (roleObj of [{id:'interviewer', title:'Interviewer'}, {id:'pm', title:'Project Manager'}, {id:'hr', title:'HR'}]; track roleObj.id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
{{ roleObj.title }}'s Comments ({{ getEvaluationByRole(roleObj.id)?.evaluatorId?.name || '' }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ ev.comments || 'No comments provided.' }}
{{ getEvaluationByRole(roleObj.id)?.comments || '' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (ev.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + ev.evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
@if (getEvaluationByRole(roleObj.id)?.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + getEvaluationByRole(roleObj.id).evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ ev.date | date:'shortDate' }}</span>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ getEvaluationByRole(roleObj.id)?.date ? (getEvaluationByRole(roleObj.id).date | date:'shortDate') : '' }}</span>
</div>
</div>
</div>
......
......@@ -251,6 +251,12 @@ export class IndividualInterviewComponent implements OnInit {
isPdfGenerating = signal(false);
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;
......
......@@ -50,8 +50,40 @@
.option-text { flex: 1; }
.check-icon { color: var(--accent-primary); font-size: 20px; }
.quiz-nav { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.question-dots { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
/* Dots: scrollable strip with arrow buttons */
.quiz-nav { display: flex; justify-content: center; align-items: center; gap: 16px; position: relative; }
.quiz-nav .btn { position: absolute; }
.quiz-nav .btn:first-child { left: 0; }
.quiz-nav .btn:last-child { right: 0; }
.dots-wrapper {
display: flex;
align-items: center;
gap: 6px;
flex: 0 1 350px; /* max width for the dots wrapper */
min-width: 0;
overflow: hidden;
}
.dots-scroll-btn {
flex-shrink: 0;
width: 32px; height: 32px; border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-secondary);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s;
padding: 0;
}
.dots-scroll-btn:hover { background: var(--bg-hover); border-color: var(--border-strong); color: var(--text-primary); }
.dots-scroll-btn .material-symbols-rounded { font-size: 20px; }
.question-dots {
display: flex; gap: 6px;
overflow-x: auto; flex: 1;
scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE */
padding: 4px 2px;
}
.question-dots::-webkit-scrollbar { display: none; /* Chrome/Safari */ }
.dot {
width: 32px; height: 32px; border-radius: var(--radius-sm); border: 1px solid var(--border-color);
background: transparent; color: var(--text-muted); font-size: 12px; font-weight: 600;
......@@ -65,6 +97,7 @@
@media (max-width: 640px) {
.quiz-page { padding: 16px; }
.question-card { padding: 20px; }
.quiz-nav { flex-direction: column; }
.question-dots { order: -1; }
.quiz-nav { flex-direction: column; position: static; }
.quiz-nav .btn { position: static; width: 100%; }
.dots-wrapper { order: -1; width: 100%; flex: 1; max-width: none; }
}
......@@ -60,11 +60,19 @@
<span class="material-symbols-rounded">arrow_back</span> Previous
</button>
<div class="question-dots">
<div class="dots-wrapper">
<button class="dots-scroll-btn" (click)="scrollDots(-1)" aria-label="Scroll left">
<span class="material-symbols-rounded">chevron_left</span>
</button>
<div class="question-dots" #dotsContainer>
@for (q of questions(); track q._id; let i = $index) {
<button class="dot" [class.active]="i === currentIndex()" [class.answered]="isAnswered(i)" (click)="goTo(i)">{{ i + 1 }}</button>
}
</div>
<button class="dots-scroll-btn" (click)="scrollDots(1)" aria-label="Scroll right">
<span class="material-symbols-rounded">chevron_right</span>
</button>
</div>
@if (currentIndex() < questions().length - 1) {
<button class="btn btn-primary" (click)="next()">Next <span class="material-symbols-rounded">arrow_forward</span></button>
......
import { Component, OnInit, OnDestroy, signal, computed } from '@angular/core';
import { Component, OnInit, OnDestroy, signal, computed, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
......@@ -16,6 +16,8 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
currentIndex = signal<number>(0);
answers = signal<Map<string, string[]>>(new Map());
@ViewChild('dotsContainer') dotsContainer!: ElementRef<HTMLDivElement>;
timeLeft = signal<number>(0);
timerInterval: any;
startTime = 0;
......@@ -117,6 +119,11 @@ export class TakeQuizComponent implements OnInit, OnDestroy {
prev(): void { this.goTo(this.currentIndex() - 1); }
next(): void { this.goTo(this.currentIndex() + 1); }
scrollDots(direction: number): void {
const el = this.dotsContainer?.nativeElement;
if (el) el.scrollBy({ left: direction * 120, behavior: 'smooth' });
}
submitQuiz(): void {
if (this.submitting() || this.submitted()) return;
this.submitting.set(true);
......
......@@ -56,27 +56,80 @@
.meta-item { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 16px; color: var(--text-muted); }
/* Candidate chips row */
.candidate-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.candidate-chip {
padding: 4px 12px; border-radius: 16px; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 600; color: #fff!important;
border: 2px solid var(--bg-card); background: #667eea;
transition: transform 0.15s;
white-space: nowrap;
}
.candidate-chip:hover { transform: scale(1.15); z-index: 1; }
.candidate-chip.clickable-chip { cursor: pointer; }
.candidate-chip.clickable-chip:hover { transform: scale(1.12); box-shadow: 0 4px 12px rgba(0,0,0,0.18); }
.candidate-chip.badge-warning { background: #f59e0b; }
.candidate-chip.badge-info { background: #3b82f6; }
.candidate-chip.badge-success { background: #22c55e; }
.candidate-chip.badge-purple { background: #a855f7; }
.iv-card-bottom { display: flex; justify-content: flex-end; }
.status-summary { font-size: 12px; color: var(--text-muted); font-style: italic; }
/* Candidate pills row */
.candidate-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.candidate-pill {
display: flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px;
border-radius: 20px; background: var(--bg-hover); border: 1px solid var(--border-color);
transition: all 0.2s;
}
.candidate-pill:hover { border-color: rgba(102,126,234,0.4); transform: translateY(-1px); }
.cp-avatar {
width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 11px; font-weight: 700; color: #fff; background: #667eea;
}
.cp-name { font-size: 12px; font-weight: 500; color: var(--text-primary); }
/* ⚠ badge on chip */
.candidate-pill.badge-warning .cp-avatar { background: #f59e0b; }
.candidate-pill.badge-info .cp-avatar { background: #3b82f6; }
.candidate-pill.badge-success .cp-avatar { background: #22c55e; }
.candidate-pill.badge-purple .cp-avatar { background: #a855f7; }
/* 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); }
}
/* ⚠ badge on chip (kept for modal usage) */
.chip-alert { font-size: 11px; margin-right: 3px; }
/* Evaluation pending badge inside candidate row name */
......@@ -90,18 +143,95 @@
/* Clickable candidate left section (button reset) */
.cr-clickable {
display: flex; align-items: center; gap: 12px;
background: none; border: none; cursor: pointer; text-align: left;
background: none; border: none; text-align: left;
padding: 6px 10px; border-radius: 10px; transition: background 0.15s;
min-width: 160px; flex-shrink: 0;
}
.cr-clickable:hover { background: rgba(102,126,234,0.08); }
/* Hint text at end of bottom row */
.cr-open-hint { font-size: 11px; color: var(--text-muted); margin-left: auto; font-style: italic; }
/* ─── iv-card-bottom ─────────────────────────────────────── */
.iv-card-bottom { display: flex; justify-content: flex-start; margin-top: 10px; }
.status-summary { font-size: 12px; color: var(--text-muted); font-style: italic; }
/* ═══════════════════════════════════════════════════════
BADGES
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; }
.badge { padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.badge-warning { background: rgba(245,158,11,0.1); color: #f59e0b; }
.badge-info { background: rgba(59,130,246,0.1); color: #3b82f6; }
......@@ -336,9 +466,12 @@
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.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);
overflow-x: auto; scrollbar-width: none;
}
.candidate-row-bottom::-webkit-scrollbar { display: none; }
.candidate-row-bottom > * { flex-shrink: 0; }
/* Progress steps (mini) */
.progress-steps { display: flex; align-items: center; }
......
......@@ -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,20 +108,41 @@
}
</div>
<!-- Candidate progress chips — click any chip to open the evaluation panel directly -->
<div class="candidate-chips">
<!-- 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) {
<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 }}
<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">
......@@ -413,21 +449,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 +483,22 @@
</div>
</div>
<!-- Bottom row: status badge + quiz scores (no accept/reject here) -->
<div class="candidate-row-bottom">
<!-- 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>
}
@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>
}
}
<span class="cr-open-hint">Click name to evaluate →</span>
</div>
</div>
......@@ -626,7 +658,7 @@
</div>
<!-- Final Decision — Admin and HR, only when all evaluations done -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') && allMemberEvaluationsDone() && selectedMember().status !== 'completed') {
@if (authService.getUserRole() === 'admin' && allMemberEvaluationsDone() && selectedMember().status !== 'completed') {
<div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons">
......
......@@ -15,13 +15,13 @@ export interface QuizSet {
}
@Component({
selector: 'app-group-interview',
selector: 'app-hr-group-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './group-interview.html',
styleUrl: './group-interview.css'
})
export class GroupInterviewComponent implements OnInit {
export class HRGroupInterviewComponent implements OnInit {
// ── List state ────────────────────────────────────────────
interviews = signal<any[]>([]);
......@@ -411,4 +411,28 @@ export class GroupInterviewComponent implements OnInit {
next: () => this.loadInterviews()
});
}
// -- 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)
);
}
}
......@@ -371,7 +371,7 @@
</div>
<!-- Final Decision (admin and hr only) -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') && selectedInterview().status !== 'completed') {
@if (authService.getUserRole() === 'admin' && selectedInterview().status !== 'completed') {
<div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons">
......@@ -457,35 +457,35 @@
</table>
<!-- Render evaluations -->
@for (ev of selectedInterview().evaluations; track ev._id) {
@for (roleObj of [{id:'interviewer', title:'Interviewer'}, {id:'pm', title:'Project Manager'}, {id:'hr', title:'HR'}]; track roleObj.id) {
<div class="print-eval-block" style="margin-bottom: 30px; page-break-inside: avoid;">
<div class="print-eval-title" style="font-weight: bold; margin-bottom: 10px;">
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
{{ roleObj.title }}'s Comments ({{ getEvaluationByRole(roleObj.id)?.evaluatorId?.name || '' }}):
</div>
<div class="print-comments" style="min-height: 80px; margin-bottom: 15px;">
{{ ev.comments || 'No comments provided.' }}
{{ getEvaluationByRole(roleObj.id)?.comments || '' }}
</div>
<div class="print-recommendation" style="margin-bottom: 20px;">
<strong style="margin-right: 15px;">Recommendation:</strong>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'offer' ? '☑' : '☐' }}</span> Offer/Hire as Intern</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'on_hold' ? '☑' : '☐' }}</span> On Hold</span>
<span style="margin-right: 15px;"><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === 'rejected' ? '☑' : '☐' }}</span> Rejected</span>
<span><span style="font-size: 16px;">{{ getEvaluationByRole(roleObj.id)?.recommendation === '2nd_round' ? '☑' : '☐' }}</span> 2nd Round</span>
</div>
<div class="print-signature-row" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div class="print-signature" style="display: flex; align-items: flex-end;">
<strong>Evaluator's Signature:</strong>
@if (ev.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + ev.evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
@if (getEvaluationByRole(roleObj.id)?.evaluatorId?.signature) {
<img [src]="'http://localhost:5000' + getEvaluationByRole(roleObj.id).evaluatorId.signature" style="max-height: 40px; margin-left: 10px;">
} @else {
<span style="border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"></span>
}
</div>
<div class="print-date" style="display: flex; align-items: flex-end;">
<strong>Date:</strong>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ ev.date | date:'shortDate' }}</span>
<span style="border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;">{{ getEvaluationByRole(roleObj.id)?.date ? (getEvaluationByRole(roleObj.id).date | date:'shortDate') : '' }}</span>
</div>
</div>
</div>
......
......@@ -5,13 +5,13 @@ import { QuizService } from '../../../services/quiz.service';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-individual-interview',
selector: 'app-hr-individual-interview',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css'
})
export class IndividualInterviewComponent implements OnInit {
export class HRIndividualInterviewComponent implements OnInit {
interviews = signal<any[]>([]);
loading = signal(true);
showCreateModal = signal(false);
......@@ -251,6 +251,12 @@ export class IndividualInterviewComponent implements OnInit {
isPdfGenerating = signal(false);
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;
......
......@@ -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;
......@@ -327,6 +529,24 @@
font-size: 20px;
}
.evaluation-summary {
margin-top: 20px;
}
.evaluation-summary button {
background-color: #ef4444;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.evaluation-summary button:hover {
background-color: #dc2626;
color: white;
}
/* Additional Styles for Contact Info & Resume */
.student-contact-info {
display: flex;
......
<div class="page-container animate-fade-in">
<div class="content-wrapper">
<!-- Breadcrumb -->
<div class="breadcrumb">
<a routerLink="/hr/users">← Back to Users</a>
@if (selectedInterview()) {
<button class="breadcrumb-btn" (click)="goBackToList()">
<span class="material-symbols-rounded">arrow_back</span> Back to Interviews
</button>
} @else {
<a routerLink="/hr/users" class="breadcrumb-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Users
</a>
}
</div>
@if (loading()) {
@if (loadingInterviews()) {
<div class="loading-state">
<div class="loader"></div>
<p>Loading history...</p>
<p>Loading candidate data...</p>
</div>
} @else {
<!-- ========== CANDIDATE HEADER CARD ========== -->
@if (user()) {
<div class="student-header">
<div class="student-profile">
......@@ -26,7 +38,6 @@
</span>
}
</div>
@if (user().resume) {
<div class="student-resume-info">
<a [href]="getResumeUrl(user().resume)" target="_blank" class="resume-badge">
......@@ -54,20 +65,126 @@
</div>
}
<h2 class="section-title">Test History</h2>
<!-- ========== INTERVIEW LIST VIEW ========== -->
@if (!selectedInterview()) {
<div class="section-header">
<h2 class="section-title">
<span class="material-symbols-rounded section-icon">work_history</span>
Interview History
</h2>
<span class="interview-count-badge">{{ interviews().length }} Interview{{ interviews().length !== 1 ? 's' : '' }}</span>
</div>
@if (submissions().length === 0) {
@if (interviews().length === 0) {
<div class="empty-state">
<span class="empty-icon">📋</span>
<h3>No tests taken yet</h3>
<p>This student hasn't taken any quizzes.</p>
<span class="material-symbols-rounded empty-icon">event_busy</span>
<h3>No interviews found</h3>
<p>This candidate hasn't been assigned any interviews yet.</p>
</div>
} @else {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>Quiz</th>
<th>Interview ID</th>
<th>Position</th>
<th>Interviewer</th>
<th>Quizzes</th>
<th>Status</th>
<th>Date</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (interview of interviews(); track interview._id; let i = $index) {
<tr class="interview-row">
<td>
<span class="interview-seq">INT-{{ String(i + 1).padStart(3, '0') }}</span>
</td>
<td class="interview-position">{{ interview.position || '—' }}</td>
<td>
<div class="interviewer-cell">
<div class="interviewer-avatar">{{ interview.interviewerId?.name?.charAt(0) || '?' }}</div>
<span>{{ interview.interviewerId?.name || 'Unassigned' }}</span>
</div>
</td>
<td>
<span class="quiz-count-badge">{{ (interview.quizzes || []).length }} Quiz{{ (interview.quizzes || []).length !== 1 ? 'zes' : '' }}</span>
</td>
<td>
<span class="status-badge" [ngClass]="getStatusClass(interview.status)">
<span class="status-dot"></span>
{{ getStatusLabel(interview.status) }}
</span>
</td>
<td class="date-cell">{{ interview.dateOfInterview | date:'dd MMM yyyy' }}</td>
<td>
<button class="view-btn" (click)="selectInterview(interview)">
View Quizzes <span class="material-symbols-rounded" style="font-size:16px;vertical-align:middle;">arrow_forward</span>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
} @else {
<!-- ========== INTERVIEW DETAIL / QUIZ DRILL-DOWN ========== -->
<div class="interview-detail-header card card-padding" style="margin-bottom: 32px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px;">
<div>
<p class="section-label-sm">Interview Details</p>
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: var(--text-primary);">{{ selectedInterview().position }}</h2>
<div class="student-contact-info" style="margin-top: 8px;">
<span class="contact-item">
<span class="material-symbols-rounded">person</span> {{ selectedInterview().interviewerId?.name || 'Unassigned' }}
</span>
<span class="contact-item">
<span class="material-symbols-rounded">calendar_today</span> {{ selectedInterview().dateOfInterview | date:'dd MMM yyyy' }}
</span>
<span class="contact-item">
<span class="material-symbols-rounded">source</span> {{ selectedInterview().source || 'N/A' }}
</span>
</div>
</div>
<span class="status-badge status-badge-lg" [ngClass]="getStatusClass(selectedInterview().status)">
<span class="status-dot"></span>
{{ getStatusLabel(selectedInterview().status) }}
</span>
</div>
</div>
<!-- Quiz table within interview -->
<div class="section-header">
<h2 class="section-title">
<span class="material-symbols-rounded section-icon">quiz</span>
Assessment Rounds
</h2>
</div>
@if (loadingSubmissions()) {
<div class="loading-state">
<div class="loader"></div>
<p>Loading quiz results...</p>
</div>
} @else if ((selectedInterview().quizzes || []).length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">assignment</span>
<h3>No quizzes assigned</h3>
<p>No assessment rounds were linked to this interview.</p>
</div>
} @else {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>Quiz ID</th>
<th>Quiz Name</th>
<th>Topic</th>
<th>Level</th>
<th>Comfort Level</th>
<th>Score</th>
<th>Percentage</th>
<th>Time Taken</th>
......@@ -76,11 +193,33 @@
</tr>
</thead>
<tbody>
@for (sub of submissions(); track sub._id) {
@for (iq of selectedInterview().quizzes; track iq._id; let i = $index) {
<tr>
<td class="quiz-name">{{ sub.quizId?.title || 'Deleted Quiz' }}</td>
<td><span class="score-badge">{{ sub.score }}/{{ sub.totalMarks }}</span></td>
<td>
<code class="quiz-id-badge">{{ getQuizId(iq, i) }}</code>
</td>
<td class="quiz-name">{{ iq.quizId?.title || iq.title || 'Quiz' }}</td>
<td>{{ iq.quizId?.category || 'N/A' }}</td>
<td>
<span class="diff-badge"
[class.diff-easy]="(iq.quizId?.difficulty || '').toLowerCase() === 'easy'"
[class.diff-medium]="(iq.quizId?.difficulty || '').toLowerCase() === 'medium'"
[class.diff-hard]="(iq.quizId?.difficulty || '').toLowerCase() === 'hard'">
{{ iq.quizId?.difficulty | uppercase }}
</span>
</td>
<td>{{ getComfortLevel(iq.quizId?.category) }}</td>
<td>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
<span class="score-badge">{{ sub.score }}/{{ sub.totalMarks }}</span>
} @else if (iq.score !== null && iq.score !== undefined) {
<span class="score-badge">{{ iq.score }}/{{ iq.totalMarks }}</span>
} @else {
<span class="not-taken">Not Taken</span>
}
</td>
<td>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
......@@ -88,11 +227,36 @@
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
} @else if (iq.percentage !== null && iq.percentage !== undefined) {
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="iq.percentage"
[class.good]="iq.percentage >= 70"
[class.avg]="iq.percentage >= 40 && iq.percentage < 70"
[class.poor]="iq.percentage < 40"></div>
</div>
<span class="percent-text">{{ iq.percentage }}%</span>
} @else {
<span class="not-taken"></span>
}
</td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'medium' }}</td>
<td>
<a [routerLink]="['/hr/submissions', sub._id]" class="view-btn">View Details</a>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
{{ formatTime(sub.timeTaken) }}
} @else { — }
</td>
<td class="date-cell">
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
{{ sub.submittedAt | date:'dd MMM yyyy' }}
} @else { — }
</td>
<td>
@if (getSubmissionForQuiz(iq.quizId?._id || iq.quizId); as sub) {
<a [routerLink]="['/hr/submissions', sub._id]" class="view-btn">
Review <span class="material-symbols-rounded" style="font-size:16px;vertical-align:middle;">open_in_new</span>
</a>
} @else {
<span class="not-taken">Pending</span>
}
</td>
</tr>
}
......@@ -101,6 +265,8 @@
</div>
}
}
}
</div>
</div>
......
......@@ -5,26 +5,43 @@ import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-user-history',
selector: 'app-hr-user-history',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './user-history.html',
styleUrl: './user-history.css',
styleUrl: './user-history.css'
})
export class HRUserHistoryComponent {
export class HRUserHistoryComponent 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: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'beginner', label: 'Fresher' },
{ value: 'intermediate', label: 'Intern' },
{ value: 'advanced', label: 'Intermediate' },
{ 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,
......@@ -33,28 +50,63 @@ export class HRUserHistoryComponent {
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.loading.set(false)
error: () => this.loadingInterviews.set(false)
});
}
selectInterview(interview: any): void {
this.selectedInterview.set(interview);
// Load submissions for the quizzes in this interview
this.loadingSubmissions.set(true);
this.quizService.getUserHistory(this.userId).subscribe({
next: (res) => {
this.submissions.set(res.submissions || []);
this.loadingSubmissions.set(false);
},
error: () => this.loadingSubmissions.set(false)
});
}
goBackToList(): void {
this.selectedInterview.set(null);
this.submissions.set([]);
}
/** Find submission for a given quizId from the loaded submissions */
getSubmissionForQuiz(quizId: any): any {
if (!quizId) return null;
const qidStr = quizId.toString();
return this.submissions().find((s: any) =>
(s.quizId?._id?.toString() || s.quizId?.toString()) === qidStr
) || null;
}
/** Generate a quiz ID like Q_Aptitude_BEG_001 for a given quiz within an interview */
getQuizId(interviewQuiz: any, index: number): string {
const quizData = interviewQuiz.quizId || {};
const topic = (quizData.category || 'General').replace(/\s+/g, '_');
const diff = (quizData.difficulty || 'easy').toLowerCase();
const levelCode = this.levelCodeMap[diff] || 'BEG';
const seq = String(index + 1).padStart(3, '0');
return `Q_${topic}_${levelCode}_${seq}`;
}
changeLevel(newLevel: string): void {
if (newLevel === this.currentLevel()) return;
const previousLevel = this.currentLevel();
const userRole = this.authService.currentUser()?.role || 'hr';
this.quizService.updateUserLevel(this.userId, newLevel, userRole).subscribe({
this.quizService.updateUserLevel(this.userId, newLevel).subscribe({
next: (res) => {
this.currentLevel.set(newLevel);
this.showToast(
......@@ -68,6 +120,24 @@ export class HRUserHistoryComponent {
});
}
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);
......@@ -86,15 +156,10 @@ export class HRUserHistoryComponent {
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}`;
......
# QuizMaster Pro - Implementation Plan
## Current State Analysis
- **Backend**: Express + MongoDB (Mongoose) with JWT auth, 2 roles (admin/student), Excel-based quiz creation
- **Frontend**: Angular 21 standalone components, dark theme, emoji icons, sidebar navigation
- **Issues**: Guards mostly commented out, dark-only theme, no quiz editing, no assignment system, no AI generation, no HR role
## Changes Required
### Phase 1: Backend - Models & Roles
1. **User Model** - Add `hr` role to enum: `['admin', 'hr', 'candidate']` (rename student → candidate)
2. **Quiz Model** - Add `assignToAll: false` default, ensure `category`, `assignees`, `difficulty`, `topic` fields
3. **Submission Model** - No changes needed (already tracks studentId/quizId)
4. **Middleware** - Update `authorize()` to support new roles
### Phase 2: Backend - Routes
1. **Auth routes** - Register creates 'candidate' by default, admin can create HR users
2. **Admin routes** - Full CRUD for quizzes, users, assignments; create HR users
3. **New HR routes** - Quiz CRUD, assign quizzes, view candidate results
4. **Quiz edit/delete guards** - Check `Submission.countDocuments({ quizId })` before allowing edit/delete
5. **Quiz assignment** - Assign to specific users; filter student quiz list by assignment
6. **AI quiz generation** - New endpoint using Google Gemini API
### Phase 3: Frontend - Theme System
1. **Professional white/light theme** as default (like LeetCode/Codeforces)
2. **CSS custom properties** for theming with dark mode toggle
3. **Material-inspired design** with clean typography, subtle shadows, accent colors
4. **Angular Material** integration for polished UI components
### Phase 4: Frontend - Components
1. **Quiz edit modal/page** - Edit title, timer, questions for unattempted quizzes
2. **Quiz assignment UI** - Multi-select users/groups for assignment
3. **AI generation form** - Topic, difficulty, count inputs
4. **Category/group management** - Quiz categorization
5. **Role-based navigation** - Different sidebars for Admin/HR/Candidate
6. **Guard updates** - Enable all guards with HR role support
### Phase 5: Polish
1. Professional LeetCode-like UI with clean white theme
2. Smooth transitions and micro-animations
3. Responsive design
4. Google Material icons instead of emojis
# Interviewer and Project Manager Workflow Implementation
This plan outlines the steps required to complete Task 1 (Building PM and Interviewer Dashboards & Interview Views) and Task 2 (Finalizing the PDF Document Generation & Download workflow).
## Task 1: Interviewer and Project Manager Platforms
Currently, the `ProjectManager` and `Interviewer` folders have shell components (e.g., `dashboard`, `individual-interview`, `group-interview`). We need to populate these and wire up the navigation.
### Proposed Changes
#### 1. Routing Updates (`src/app/app.routes.ts`)
Update the routes to properly map to the specific components you've created for the PM and Interviewer, rather than reusing the Admin components:
- `/pm/dashboard` -> `pages/ProjectManager/dashboard/dashboard.ts`
- `/pm/individual-interview` -> `pages/ProjectManager/individual-interview/individual-interview.ts`
- `/pm/group-interview` -> `pages/ProjectManager/group-interview/group-interview.ts`
- `/interviewer/dashboard` -> `pages/Interviewer/dashboard/dashboard.ts`
- `/interviewer/individual-interview` -> `pages/Interviewer/individual-interview/individual-interview.ts`
- `/interviewer/group-interview` -> `pages/Interviewer/group-interview/group-interview.ts`
#### 2. Layout & Sidebar (`src/app/components/layout/layout.ts` & `layout.html`)
Update the sidebar for the `pm` and `interviewer` roles to show:
- Dashboard
- Individual Interviews
- Group Interviews
- Profile
#### 3. Dashboards (`pages/ProjectManager/dashboard` & `pages/Interviewer/dashboard`)
We will mirror the look of the admin/hr dashboard but simplify it for these roles. They do not need to create quizzes or manage users. The dashboard will strictly show their pending evaluation metrics and a quick action link to navigate to their interviews.
#### 4. Individual & Group Interview Components
For both PM and Interviewer roles, we will copy the functionality from the Admin/HR `individual-interview` and `group-interview` modules. However, we will restrict administrative capabilities:
- **Remove** the "Create New Interview" button.
- **Remove** the "Delete Interview" action.
- **Remove** the "Final Decision" accept/reject section since only Admin/HR should finalize the overall hiring decision.
- **Keep** the interview list (which correctly filters to only the interviews they are assigned to).
- **Keep** the "Evaluation" form inside the interview detail modal so they can submit their own comments and recommendations.
## Task 2: Evaluation Summary Document & Download
Once the PM and Interviewer can successfully submit their evaluations, we will solidify the PDF download button logic.
Currently, we added a "Download PDF" button in the HR/Admin individual interview modal. Based on your request, we will ensure that:
1. The **Download Evaluation Summary** button is prominently visible in the Interview Details modal across all roles (Admin, HR, PM, Interviewer).
2. The button will generate the structured PDF form (matching Images 3 & 4), containing pre-filled sections for:
- Interviewer Comments, Recommendation, Signature, and Date
- PM Comments, Recommendation, Signature, and Date
- HR Comments, Recommendation, Signature, and Date
3. Data from the database will seamlessly populate these sections. If a specific crew member hasn't evaluated yet, their section will print blank so it can be physically filled if desired, just like we implemented for HR in the previous session.
## User Review Required
> [!IMPORTANT]
> - Are you okay with removing the "Create New Interview" and "Finalize Decision" buttons from the PM and Interviewer views? (Usually, only Admins or HR schedule the interviews and make the final hire/reject decision).
> - Should the "Download Evaluation Summary" button only be enabled/visible after **everyone** (HR, PM, Interviewers) has completed their evaluations, or should they be able to download a partially filled document at any time? Currently, the logic restricts downloading until everyone has finished.
Please approve this plan so I can start executing Task 1 immediately!
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