Commit 954049ba authored by Aravind RK's avatar Aravind RK

Group interview bug have corrected

parent 78c91f3e
...@@ -48,8 +48,8 @@ const userSchema = new mongoose.Schema({ ...@@ -48,8 +48,8 @@ const userSchema = new mongoose.Schema({
}, },
level: { level: {
type: String, type: String,
enum: ['beginner', 'intermediate', 'advanced', 'expert'], enum: ['Fresher', 'Intern', 'Pre final year', 'Final year'],
default: 'beginner' default: 'Fresher'
}, },
topicsOfInterest: [{ topicsOfInterest: [{
topic: { type: String, required: true }, topic: { type: String, required: true },
......
...@@ -428,9 +428,9 @@ router.put('/:id/evaluate', authorize('admin', 'hr', 'pm', 'interviewer'), async ...@@ -428,9 +428,9 @@ router.put('/:id/evaluate', authorize('admin', 'hr', 'pm', 'interviewer'), async
// ============================================================ // ============================================================
// @route PUT /api/interview/:id/decision // @route PUT /api/interview/:id/decision
// @desc Set final decision (accepted/rejected/on_hold/2nd_round) // @desc Set final decision (accepted/rejected/on_hold/2nd_round)
// @access Admin only // @access Admin, HR
// ============================================================ // ============================================================
router.put('/:id/decision', authorize('admin'), async (req, res) => { router.put('/:id/decision', authorize('admin', 'hr'), async (req, res) => {
try { try {
const { decision } = req.body; const { decision } = req.body;
if (!decision) return res.status(400).json({ message: 'Decision is required' }); if (!decision) return res.status(400).json({ message: 'Decision is required' });
...@@ -515,9 +515,9 @@ router.put('/:id/validate-coding', authorize('admin', 'hr', 'pm', 'interviewer') ...@@ -515,9 +515,9 @@ router.put('/:id/validate-coding', authorize('admin', 'hr', 'pm', 'interviewer')
// ============================================================ // ============================================================
// @route DELETE /api/interview/:id // @route DELETE /api/interview/:id
// @desc Delete an interview // @desc Delete an interview
// @access Admin only // @access Admin, HR
// ============================================================ // ============================================================
router.delete('/:id', authorize('admin'), async (req, res) => { router.delete('/:id', authorize('admin', 'hr'), async (req, res) => {
try { try {
const interview = await Interview.findById(req.params.id); const interview = await Interview.findById(req.params.id);
if (!interview) return res.status(404).json({ message: 'Interview not found' }); if (!interview) return res.status(404).json({ message: 'Interview not found' });
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -59,12 +59,15 @@ ...@@ -59,12 +59,15 @@
/* Candidate chips row */ /* Candidate chips row */
.candidate-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .candidate-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.candidate-chip { .candidate-chip {
width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; padding: 4px 12px; border-radius: 16px; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700; color: #fff; justify-content: center; font-size: 12px; font-weight: 600; color: #fff!important;
border: 2px solid var(--bg-card); background: #667eea; border: 2px solid var(--bg-card); background: #667eea;
transition: transform 0.15s; transition: transform 0.15s;
white-space: nowrap;
} }
.candidate-chip:hover { transform: scale(1.15); z-index: 1; } .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-warning { background: #f59e0b; }
.candidate-chip.badge-info { background: #3b82f6; } .candidate-chip.badge-info { background: #3b82f6; }
.candidate-chip.badge-success { background: #22c55e; } .candidate-chip.badge-success { background: #22c55e; }
...@@ -73,6 +76,29 @@ ...@@ -73,6 +76,29 @@
.iv-card-bottom { display: flex; justify-content: flex-end; } .iv-card-bottom { display: flex; justify-content: flex-end; }
.status-summary { font-size: 12px; color: var(--text-muted); font-style: italic; } .status-summary { font-size: 12px; color: var(--text-muted); font-style: italic; }
/* ⚠ badge on chip */
.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; cursor: pointer; 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; }
/* ═══════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════
BADGES BADGES
═══════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════ */
...@@ -293,20 +319,26 @@ ...@@ -293,20 +319,26 @@
/* Candidate detail list */ /* Candidate detail list */
.candidate-detail-list { display: flex; flex-direction: column; gap: 12px; } .candidate-detail-list { display: flex; flex-direction: column; gap: 12px; }
.candidate-row { .candidate-row {
display: flex; align-items: flex-start; gap: 16px; padding: 16px; display: flex; flex-direction: column; gap: 12px; padding: 16px;
border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-hover); border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-hover);
flex-wrap: wrap; transition: border-color 0.2s; transition: border-color 0.2s;
} }
.candidate-row:hover { border-color: rgba(102,126,234,0.35); } .candidate-row:hover { border-color: rgba(102,126,234,0.35); }
.candidate-row-left { display: flex; align-items: center; gap: 12px; min-width: 180px; } /* 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 { 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: 32px; height: 32px; font-size: 14px; border-radius: 8px; } .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-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.cr-email { font-size: 12px; color: var(--text-muted); } .cr-email { font-size: 12px; color: var(--text-muted); }
.candidate-row-mid { flex: 1; }
.candidate-row-mid { flex: 1; min-width: 280px; } /* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.candidate-row-right { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; } .candidate-row-bottom {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding-top: 10px; border-top: 1px solid var(--border-color);
}
/* Progress steps (mini) */ /* Progress steps (mini) */
.progress-steps { display: flex; align-items: center; } .progress-steps { display: flex; align-items: center; }
...@@ -352,6 +384,45 @@ ...@@ -352,6 +384,45 @@
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(24px); opacity: 0; } to { transform: translateY(0); 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 RESPONSIVE
═══════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════ */
...@@ -365,3 +436,41 @@ ...@@ -365,3 +436,41 @@
.candidate-row-right { align-items: flex-start; } .candidate-row-right { align-items: flex-start; }
.da-header, .da-row { grid-template-columns: 1fr; } .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;
}
}
...@@ -93,15 +93,22 @@ ...@@ -93,15 +93,22 @@
} }
</div> </div>
<!-- Candidate progress chips --> <!-- Candidate progress chips — click any chip to open the evaluation panel directly -->
<div class="candidate-chips"> <div class="candidate-chips">
@for (m of g.members; track m._id) { @for (m of g.members; track m._id) {
<span class="candidate-chip" [ngClass]="getStatusClass(m.status)" [title]="m.candidateId?.name + ' — ' + formatStatus(m.status)"> <span class="candidate-chip clickable-chip" [ngClass]="getStatusClass(m.status)"
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }} [title]="m.candidateId?.name + ' — ' + formatStatus(m.status) + (hasPendingEvaluations(m) ? ' (Evaluations pending)' : '')"
(click)="openMemberDetail(m._id, $event)">
@if (hasPendingEvaluations(m)) {
<span class="chip-alert" title="Evaluations still pending">⚠️</span>
}
{{ m.candidateId?.name }}
</span> </span>
} }
</div> </div>
<div class="iv-card-bottom"> <div class="iv-card-bottom">
<span class="status-summary">{{ groupStatusSummary(g.members) }}</span> <span class="status-summary">{{ groupStatusSummary(g.members) }}</span>
</div> </div>
...@@ -403,60 +410,65 @@ ...@@ -403,60 +410,65 @@
<div class="candidate-detail-list"> <div class="candidate-detail-list">
@for (m of selectedGroup().members; track m._id) { @for (m of selectedGroup().members; track m._id) {
<div class="candidate-row"> <div class="candidate-row">
<div class="candidate-row-left">
<div class="iv-avatar small">{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}</div> <!-- Top row: clickable avatar+name on the left, progress bar on the right -->
<div> <div class="candidate-row-top">
<div class="cr-name">{{ m.candidateId?.name }}</div> <button class="cr-clickable" (click)="openMemberDetail(m._id, $event)">
<div class="cr-email">{{ m.candidateId?.email }}</div> <div class="iv-avatar small">{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}</div>
</div> <div>
</div> <div class="cr-name">
<div class="candidate-row-mid"> {{ m.candidateId?.name }}
<!-- Progress steps mini --> @if (needsEvaluation(m)) {
<div class="progress-steps mini"> <span class="eval-pending-badge" title="Your evaluation is pending">⚠️ Evaluate</span>
<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> @if (hasPendingEvaluations(m)) {
</div> <span class="eval-pending-badge warn" title="Some evaluations are still pending"></span>
<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)"> </div>
<span class="step-dot"></span><span class="step-label">Quiz</span> <div class="cr-email">{{ m.candidateId?.email }}</div>
</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>
<div class="step-line" [class.done]="m.status==='completed'"></div> </button>
<div class="step" [class.active]="m.status==='completed'" [class.done]="m.status==='completed'"> <div class="candidate-row-mid">
<span class="step-dot"></span><span class="step-label">Done</span> <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> </div>
</div> </div>
<div class="candidate-row-right">
<!-- Bottom row: status badge + quiz scores (no accept/reject here) -->
<div class="candidate-row-bottom">
<span class="badge" [ngClass]="getStatusClass(m.status)">{{ formatStatus(m.status) }}</span> <span class="badge" [ngClass]="getStatusClass(m.status)">{{ formatStatus(m.status) }}</span>
@if (m.finalDecision !== 'pending') { @if (m.finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(m.finalDecision)">{{ formatDecision(m.finalDecision) }}</span> <span class="badge" [ngClass]="getDecisionClass(m.finalDecision)">{{ formatDecision(m.finalDecision) }}</span>
} }
<!-- Quiz scores -->
@if (m.quizzes?.length > 0) { @if (m.quizzes?.length > 0) {
<div class="quiz-scores-inline"> @for (q of m.quizzes; track q.quizId) {
@for (q of m.quizzes; track q.quizId) { <span class="quiz-score-chip" [class.completed]="q.completed">
<span class="quiz-score-chip" [class.completed]="q.completed"> {{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }}
{{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }} </span>
</span> }
}
</div>
}
<!-- Decision buttons (admin) -->
@if (authService.getUserRole() === 'admin' && m.status !== 'completed') {
<div class="mini-decision-btns">
<button class="mini-btn success" (click)="setDecision(m._id, 'accepted'); $event.stopPropagation()">Accept</button>
<button class="mini-btn danger" (click)="setDecision(m._id, 'rejected'); $event.stopPropagation()">Reject</button>
</div>
} }
<span class="cr-open-hint">Click name to evaluate →</span>
</div> </div>
</div> </div>
} }
</div> </div>
...@@ -465,7 +477,7 @@ ...@@ -465,7 +477,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@if (authService.getUserRole() === 'admin') { @if (authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') {
<button class="btn btn-danger btn-sm" (click)="deleteGroupInterview(selectedGroup().groupId)"> <button class="btn btn-danger btn-sm" (click)="deleteGroupInterview(selectedGroup().groupId)">
Delete All Interviews Delete All Interviews
</button> </button>
...@@ -476,3 +488,261 @@ ...@@ -476,3 +488,261 @@
</div> </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 (selectedMember().evaluations?.length > 0) {
<div class="eval-list">
@for (ev of 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 -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') && allMemberEvaluationsDone() && selectedMember().status !== 'completed') {
<div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons">
<button class="btn btn-success" (click)="setMemberDecision('accepted')">
<span class="material-symbols-rounded">check_circle</span> Accept
</button>
<button class="btn btn-warning" (click)="setMemberDecision('on_hold')">
<span class="material-symbols-rounded">pause_circle</span> On Hold
</button>
<button class="btn btn-danger" (click)="setMemberDecision('rejected')">
<span class="material-symbols-rounded">cancel</span> Reject
</button>
<button class="btn btn-outline" (click)="setMemberDecision('2nd_round')">
<span class="material-symbols-rounded">replay</span> 2nd Round
</button>
</div>
</div>
}
</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>
}
...@@ -74,11 +74,16 @@ export class GroupInterviewComponent implements OnInit { ...@@ -74,11 +74,16 @@ export class GroupInterviewComponent implements OnInit {
pendingSetsCount = 2; pendingSetsCount = 2;
quizSets: QuizSet[] = []; quizSets: QuizSet[] = [];
// ── Detail modal state ──────────────────────────────────── // ── Group detail modal state ─────────────────────────────
showDetailModal = signal(false); showDetailModal = signal(false);
selectedGroup = signal<any>(null); selectedGroup = signal<any>(null);
evalComment = signal('');
evalRecommendation = signal(''); // ── Per-candidate (member) detail modal ──────────────────
showMemberDetailModal = signal(false);
selectedMember = signal<any>(null); // full interview doc
memberEvalComment = '';
memberEvalRecommendation = '';
isMemberSubmitting = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {} constructor(private quizService: QuizService, public authService: AuthService) {}
...@@ -244,7 +249,7 @@ export class GroupInterviewComponent implements OnInit { ...@@ -244,7 +249,7 @@ export class GroupInterviewComponent implements OnInit {
}); });
} }
// ── Detail modal ────────────────────────────────────────── // ── Group detail modal ────────────────────────────────────
openDetail(group: any): void { openDetail(group: any): void {
this.selectedGroup.set(group); this.selectedGroup.set(group);
this.showDetailModal.set(true); this.showDetailModal.set(true);
...@@ -255,6 +260,108 @@ export class GroupInterviewComponent implements OnInit { ...@@ -255,6 +260,108 @@ export class GroupInterviewComponent implements OnInit {
this.selectedGroup.set(null); 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);
// refresh the group list so chips update
this.loadInterviews();
},
error: () => this.isMemberSubmitting.set(false)
});
}
setMemberDecision(decision: string): void {
const m = this.selectedMember();
if (!m) return;
this.quizService.setInterviewDecision(m._id, decision).subscribe({
next: res => {
this.selectedMember.set(res.interview);
this.loadInterviews();
this.loadStats();
}
});
}
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;
}
/** True when THIS user still needs to evaluate this member */
needsEvaluation(m: any): boolean {
if (m.status === 'completed') return false;
const userId = this.authService.currentUser()?.id;
const role = this.authService.getUserRole();
if (role === 'admin') return false; // admin gives final decision, not evaluation
const evaluated = m.evaluations?.some(
(e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId
);
return !evaluated;
}
/** Whether ANY assigned staff member hasn't evaluated a given candidate interview yet */
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;
}
isPdfGenerating = signal(false);
downloadEvaluationPdf(): void {
const m = this.selectedMember();
if (!m) return;
this.isPdfGenerating.set(true);
setTimeout(() => {
window.print();
this.isPdfGenerating.set(false);
}, 500);
}
// ── Helpers ─────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────
getStatusClass(status: string): string { getStatusClass(status: string): string {
const map: any = { const map: any = {
......
...@@ -370,8 +370,8 @@ ...@@ -370,8 +370,8 @@
} }
</div> </div>
<!-- Final Decision (admin only) --> <!-- Final Decision (admin and hr only) -->
@if (authService.getUserRole() === 'admin' && selectedInterview().status !== 'completed') { @if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') && selectedInterview().status !== 'completed') {
<div class="detail-section decision-section"> <div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3> <h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons"> <div class="decision-buttons">
...@@ -393,7 +393,7 @@ ...@@ -393,7 +393,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@if (authService.getUserRole() === 'admin') { @if (authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') {
<button class="btn btn-danger btn-sm" (click)="deleteInterview(selectedInterview()._id)">Delete Interview</button> <button class="btn btn-danger btn-sm" (click)="deleteInterview(selectedInterview()._id)">Delete Interview</button>
} }
<button class="btn btn-outline" (click)="closeDetail()">Close</button> <button class="btn btn-outline" (click)="closeDetail()">Close</button>
......
...@@ -33,8 +33,8 @@ ...@@ -33,8 +33,8 @@
<div class="user-card-info"> <div class="user-card-info">
<div class="name-row"> <div class="name-row">
<h4>{{ user.name }}</h4> <h4>{{ user.name }}</h4>
<span class="level-badge" [attr.data-level]="user.level || 'beginner'"> <span class="level-badge" [attr.data-level]="user.level || 'Fresher'">
{{ (user.level || 'beginner') | titlecase }} {{ (user.level || 'Fresher') | titlecase }}
</span> </span>
</div> </div>
<p>{{ user.email }}</p> <p>{{ user.email }}</p>
......
/* ═══════════════════════════════════════════════════════
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 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; }
/* ⚠ badge on chip */
.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; cursor: pointer; 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; }
/* ═══════════════════════════════════════════════════════
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); }
.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: wrap; align-items: center; gap: 8px;
padding-top: 10px; border-top: 1px solid var(--border-color);
}
/* 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;
}
}
<p>group-interview works!</p> <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>
<button class="btn btn-primary" (click)="openCreateModal()">
<span class="material-symbols-rounded">group_add</span>
Create Group Interview
</button>
</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)">
<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-badges">
<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>
}
</div>
</div>
<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>
<!-- Candidate progress chips — click any chip to open the evaluation panel directly -->
<div class="candidate-chips">
@for (m of g.members; track m._id) {
<span class="candidate-chip clickable-chip" [ngClass]="getStatusClass(m.status)"
[title]="m.candidateId?.name + ' — ' + formatStatus(m.status) + (hasPendingEvaluations(m) ? ' (Evaluations pending)' : '')"
(click)="openMemberDetail(m._id, $event)">
@if (hasPendingEvaluations(m)) {
<span class="chip-alert" title="Evaluations still pending">⚠️</span>
}
{{ m.candidateId?.name }}
</span>
}
</div>
<div class="iv-card-bottom">
<span class="status-summary">{{ groupStatusSummary(g.members) }}</span>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
CREATE GROUP INTERVIEW MODAL
═══════════════════════════════════════════════════════════ -->
@if (showCreateModal()) {
<div class="modal-overlay" (click)="closeCreateModal()">
<div class="modal-container modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Create Group Interview</h2>
<button class="icon-btn" (click)="closeCreateModal()"><span class="material-symbols-rounded">close</span></button>
</div>
@if (successMsg()) {
<div class="success-banner">
<span class="material-symbols-rounded">check_circle</span>
{{ successMsg() }}
<button class="btn btn-outline btn-sm" (click)="closeCreateModal()">Close</button>
</div>
} @else {
<div class="modal-body">
<!-- Staff assignment -->
<div class="section-title">
<span class="material-symbols-rounded">badge</span> Staff Assignment
</div>
<div class="form-row three-col">
<div class="form-group">
<label class="form-label">Interviewers</label>
<div class="checklist-box">
@for (i of interviewers(); track i._id) {
<label class="check-item">
<input type="checkbox" [value]="i._id"
(change)="toggleSelection($event, newGroupInterview.assignedInterviewers)">
<span>{{ i.name }}</span>
</label>
}
@if (interviewers().length === 0) { <p class="text-muted">No interviewers found</p> }
</div>
</div>
<div class="form-group">
<label class="form-label">HR Personnel</label>
<div class="checklist-box">
@for (h of hrs(); track h._id) {
<label class="check-item">
<input type="checkbox" [value]="h._id"
(change)="toggleSelection($event, newGroupInterview.assignedHRs)">
<span>{{ h.name }}</span>
</label>
}
@if (hrs().length === 0) { <p class="text-muted">No HR found</p> }
</div>
</div>
<div class="form-group">
<label class="form-label">Project Managers</label>
<div class="checklist-box">
@for (p of pms(); track p._id) {
<label class="check-item">
<input type="checkbox" [value]="p._id"
(change)="toggleSelection($event, newGroupInterview.assignedPMs)">
<span>{{ p.name }}</span>
</label>
}
@if (pms().length === 0) { <p class="text-muted">No PMs found</p> }
</div>
</div>
</div>
<!-- Group + Position -->
<div class="section-title">
<span class="material-symbols-rounded">groups</span> Group & Position
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Group *</label>
<select class="form-input" [(ngModel)]="newGroupInterview.groupName" (ngModelChange)="onGroupChange()">
<option value="">Select group</option>
@for (g of groups(); track g) {
<option [value]="g">{{ g }}</option>
}
</select>
@if (groupMembers().length > 0) {
<span class="field-hint">
<span class="material-symbols-rounded">person</span>
{{ groupMembers().length }} candidate{{ groupMembers().length !== 1 ? 's' : '' }} in this group
</span>
}
</div>
<div class="form-group">
<label class="form-label">Position *</label>
<input class="form-input" [(ngModel)]="newGroupInterview.position" placeholder="e.g., MEAN Stack Developer">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Source</label>
<input class="form-input" [(ngModel)]="newGroupInterview.source" placeholder="e.g., LinkedIn, Campus">
</div>
<div class="form-group">
<label class="form-label">Date of Interview</label>
<input class="form-input" type="date" [(ngModel)]="newGroupInterview.dateOfInterview">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Tech Stack</label>
<input class="form-input" [(ngModel)]="newGroupInterview.techStack" placeholder="e.g., MERN, Java, Python">
</div>
</div>
<!-- Quiz Configuration -->
<div class="section-title">
<span class="material-symbols-rounded">quiz</span> Quiz Configuration
@if (quizSets.length > 0) {
<button class="btn btn-outline btn-sm" style="margin-left: auto;" (click)="addMoreSet()">
<span class="material-symbols-rounded">add</span> Add Set
</button>
}
</div>
@if (quizSets.length === 0 && !showQuizSetup) {
<div class="quiz-empty-hint">
<span class="material-symbols-rounded">quiz</span>
<p>No quiz sets configured yet.</p>
<button class="btn btn-primary" (click)="showQuizSetup = true">
<span class="material-symbols-rounded">add</span> Add Quiz
</button>
</div>
}
@if (showQuizSetup) {
<div class="quiz-setup-prompt">
<span class="material-symbols-rounded">help_outline</span>
<span>How many quiz sets?</span>
<input type="number" min="1" max="10" class="sets-count-input"
[(ngModel)]="pendingSetsCount" placeholder="e.g. 2">
<button class="btn btn-primary btn-sm" (click)="confirmSetsCount()">Confirm</button>
<button class="btn btn-outline btn-sm" (click)="showQuizSetup = false">Cancel</button>
</div>
}
<!-- Quiz Sets -->
@for (set of quizSets; track $index; let si = $index) {
<div class="quiz-set-block">
<div class="quiz-set-header">
<div class="quiz-set-title">
<span class="set-badge">Set {{ si + 1 }}</span>
@if (isSingleQuizSet(set)) {
<span class="set-note">📌 Assigned to all candidates</span>
} @else if (isMultiQuizSet(set)) {
<span class="set-note">🎯 {{ validEntries(set).length }} quizzes · {{ set.mode === 'random' ? '🎲 Random' : 'Direct' }} assignment</span>
}
</div>
<button class="icon-btn danger" (click)="removeQuizSet(si)">
<span class="material-symbols-rounded">delete</span>
</button>
</div>
<!-- Quiz entries in this set -->
@for (entry of set.quizEntries; track $index; let ei = $index) {
<div class="quiz-entry-row">
<select class="form-input" [(ngModel)]="entry.quizId">
<option value="">— Select quiz —</option>
@for (q of getAvailableQuizzes(si, ei); track q._id) {
<option [value]="q._id">{{ q.title }}</option>
}
</select>
<button class="icon-btn" (click)="removeQuizEntry(si, ei)">
<span class="material-symbols-rounded">remove_circle_outline</span>
</button>
</div>
}
<button class="btn btn-ghost btn-sm" (click)="addQuizToSet(si)">
<span class="material-symbols-rounded">add</span> Add quiz to Set {{ si + 1 }}
</button>
<!-- Assignment mode — only shown when multiple quizzes -->
@if (isMultiQuizSet(set)) {
<div class="assignment-mode-row">
<span class="mode-label">Assignment Mode:</span>
<div class="mode-toggle">
<button class="mode-btn" [class.active]="set.mode === 'random'" (click)="set.mode = 'random'">
<span class="material-symbols-rounded">shuffle</span> Random
</button>
<button class="mode-btn" [class.active]="set.mode === 'direct'" (click)="set.mode = 'direct'">
<span class="material-symbols-rounded">assignment_ind</span> Direct
</button>
</div>
</div>
<!-- Direct assignment table -->
@if (set.mode === 'direct') {
@if (groupMembers().length === 0) {
<p class="text-muted" style="font-size:13px; margin-top:8px;">
⚠️ Select a group above to assign quizzes to individual candidates.
</p>
} @else {
<div class="direct-assignment-table">
<div class="da-header">
<span>Candidate</span>
<span>Assigned Quiz (Set {{ si + 1 }})</span>
</div>
@for (member of groupMembers(); track member._id) {
<div class="da-row">
<div class="da-candidate">
<div class="da-avatar">{{ member.name.charAt(0).toUpperCase() }}</div>
<span>{{ member.name }}</span>
</div>
<select class="form-input" [(ngModel)]="set.directAssignments[member._id]">
<option value="">— Select —</option>
@for (entry of validEntries(set); track entry.quizId) {
<option [value]="entry.quizId">{{ getQuizTitle(entry.quizId) }}</option>
}
</select>
</div>
}
</div>
}
}
}
</div>
}
</div><!-- /modal-body -->
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeCreateModal()">Cancel</button>
<button class="btn btn-primary"
(click)="createGroupInterview()"
[disabled]="isSubmitting() || !newGroupInterview.groupName || !newGroupInterview.position">
@if (isSubmitting()) {
<span class="spinner spinner-sm"></span> Creating...
} @else {
<span class="material-symbols-rounded">rocket_launch</span> Create Group Interview
}
</button>
</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">
<button class="cr-clickable" (click)="openMemberDetail(m._id, $event)">
<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 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 + quiz scores (no accept/reject here) -->
<div class="candidate-row-bottom">
<span class="badge" [ngClass]="getStatusClass(m.status)">{{ formatStatus(m.status) }}</span>
@if (m.finalDecision !== 'pending') {
<span class="badge" [ngClass]="getDecisionClass(m.finalDecision)">{{ formatDecision(m.finalDecision) }}</span>
}
@if (m.quizzes?.length > 0) {
@for (q of m.quizzes; track q.quizId) {
<span class="quiz-score-chip" [class.completed]="q.completed">
{{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }}
</span>
}
}
<span class="cr-open-hint">Click name to evaluate →</span>
</div>
</div>
}
</div>
</div>
</div>
<div class="modal-footer">
@if (authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') {
<button class="btn btn-danger btn-sm" (click)="deleteGroupInterview(selectedGroup().groupId)">
Delete All Interviews
</button>
}
<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 (selectedMember().evaluations?.length > 0) {
<div class="eval-list">
@for (ev of 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 -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') && allMemberEvaluationsDone() && selectedMember().status !== 'completed') {
<div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons">
<button class="btn btn-success" (click)="setMemberDecision('accepted')">
<span class="material-symbols-rounded">check_circle</span> Accept
</button>
<button class="btn btn-warning" (click)="setMemberDecision('on_hold')">
<span class="material-symbols-rounded">pause_circle</span> On Hold
</button>
<button class="btn btn-danger" (click)="setMemberDecision('rejected')">
<span class="material-symbols-rounded">cancel</span> Reject
</button>
<button class="btn btn-outline" (click)="setMemberDecision('2nd_round')">
<span class="material-symbols-rounded">replay</span> 2nd Round
</button>
</div>
</div>
}
</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 } from '@angular/core'; 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';
export interface QuizEntry {
quizId: string;
}
export interface QuizSet {
quizEntries: QuizEntry[];
mode: 'random' | 'direct';
directAssignments: { [candidateId: string]: string };
}
@Component({ @Component({
selector: 'app-group-interview', selector: 'app-group-interview',
imports: [], standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './group-interview.html', templateUrl: './group-interview.html',
styleUrl: './group-interview.css', styleUrl: './group-interview.css'
}) })
export class GroupInterviewComponent {} export class GroupInterviewComponent 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 });
// ── Create modal state ────────────────────────────────────
showCreateModal = signal(false);
isSubmitting = signal(false);
successMsg = signal('');
// dropdown data
groups = signal<any[]>([]);
interviewers = signal<any[]>([]);
hrs = signal<any[]>([]);
pms = signal<any[]>([]);
quizzes = signal<any[]>([]);
groupMembers = signal<any[]>([]);
// form
newGroupInterview = this.blankForm();
// quiz-set builder
showQuizSetup = false;
pendingSetsCount = 2;
quizSets: QuizSet[] = [];
// ── Group detail modal state ─────────────────────────────
showDetailModal = signal(false);
selectedGroup = signal<any>(null);
// ── Per-candidate (member) detail modal ──────────────────
showMemberDetailModal = signal(false);
selectedMember = signal<any>(null); // full interview doc
memberEvalComment = '';
memberEvalRecommendation = '';
isMemberSubmitting = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
ngOnInit(): void {
this.loadInterviews();
this.loadStats();
}
// ── Data loaders ──────────────────────────────────────────
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(); }
// ── Create modal ──────────────────────────────────────────
openCreateModal(): void {
this.newGroupInterview = this.blankForm();
this.quizSets = [];
this.showQuizSetup = false;
this.pendingSetsCount = 2;
this.groupMembers.set([]);
this.successMsg.set('');
this.quizService.getGroups().subscribe({ next: r => this.groups.set(r.groups || []) });
this.quizService.getInterviewers().subscribe({
next: r => {
const staff = r.interviewers || [];
this.interviewers.set(staff.filter((s: any) => s.role === 'interviewer'));
this.pms.set(staff.filter((s: any) => s.role === 'pm'));
this.hrs.set(staff.filter((s: any) => s.role === 'hr'));
}
});
this.quizService.getAdminQuizzes().subscribe({ next: r => this.quizzes.set(r.quizzes || []) });
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
this.successMsg.set('');
}
blankForm() {
return {
groupName: '',
assignedInterviewers: [] as string[],
assignedHRs: [] as string[],
assignedPMs: [] as string[],
position: '',
techStack: '',
source: '',
dateOfInterview: new Date().toISOString().split('T')[0]
};
}
toggleSelection(event: any, array: string[]): void {
const val = event.target.value;
if (event.target.checked) { array.push(val); }
else { const i = array.indexOf(val); if (i >= 0) array.splice(i, 1); }
}
onGroupChange(): void {
if (!this.newGroupInterview.groupName) { this.groupMembers.set([]); return; }
this.quizService.getGroupMembers(this.newGroupInterview.groupName).subscribe({
next: r => {
this.groupMembers.set(r.candidates || []);
// reset direct assignments when group changes
for (const set of this.quizSets) set.directAssignments = {};
}
});
}
// ── Quiz Sets builder ─────────────────────────────────────
confirmSetsCount(): void {
const n = Math.max(1, Math.min(10, this.pendingSetsCount || 1));
this.quizSets = Array.from({ length: n }, (_, i) => ({
quizEntries: [],
mode: 'random',
directAssignments: {}
}));
this.showQuizSetup = false;
}
addQuizToSet(setIdx: number): void {
this.quizSets[setIdx].quizEntries.push({ quizId: '' });
}
removeQuizEntry(setIdx: number, entryIdx: number): void {
this.quizSets[setIdx].quizEntries.splice(entryIdx, 1);
}
removeQuizSet(setIdx: number): void {
this.quizSets.splice(setIdx, 1);
}
addMoreSet(): void {
this.quizSets.push({ quizEntries: [], mode: 'random', directAssignments: {} });
}
/** Quizzes available for a specific dropdown (setIdx, entryIdx).
* Excludes any quizId already chosen in ANY other (set, entry) pair. */
getAvailableQuizzes(setIdx: number, entryIdx: number): any[] {
const usedIds = new Set<string>();
this.quizSets.forEach((set, si) => {
set.quizEntries.forEach((entry, ei) => {
if (entry.quizId && !(si === setIdx && ei === entryIdx)) usedIds.add(entry.quizId);
});
});
return this.quizzes().filter(q => !usedIds.has(q._id));
}
getQuizTitle(quizId: string): string {
return this.quizzes().find(q => q._id === quizId)?.title || quizId;
}
validEntries(set: QuizSet): QuizEntry[] {
return set.quizEntries.filter(e => e.quizId);
}
isSingleQuizSet(set: QuizSet): boolean {
return this.validEntries(set).length === 1;
}
isMultiQuizSet(set: QuizSet): boolean {
return this.validEntries(set).length > 1;
}
// ── Create interview ──────────────────────────────────────
createGroupInterview(): void {
if (!this.newGroupInterview.groupName || !this.newGroupInterview.position) return;
this.isSubmitting.set(true);
const payload = {
...this.newGroupInterview,
quizSets: this.quizSets.map(set => ({
quizEntries: set.quizEntries.filter(e => e.quizId),
mode: set.mode,
directAssignments: set.directAssignments
}))
};
this.quizService.createGroupInterview(payload).subscribe({
next: res => {
this.isSubmitting.set(false);
this.successMsg.set(res.message || `Interview created for ${res.count} candidates!`);
this.loadInterviews();
this.loadStats();
},
error: err => {
this.isSubmitting.set(false);
alert(err.error?.message || 'Failed to create group interview');
}
});
}
// ── 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);
// refresh the group list so chips update
this.loadInterviews();
},
error: () => this.isMemberSubmitting.set(false)
});
}
setMemberDecision(decision: string): void {
const m = this.selectedMember();
if (!m) return;
this.quizService.setInterviewDecision(m._id, decision).subscribe({
next: res => {
this.selectedMember.set(res.interview);
this.loadInterviews();
this.loadStats();
}
});
}
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;
}
/** True when THIS user still needs to evaluate this member */
needsEvaluation(m: any): boolean {
if (m.status === 'completed') return false;
const userId = this.authService.currentUser()?.id;
const role = this.authService.getUserRole();
if (role === 'admin') return false; // admin gives final decision, not evaluation
const evaluated = m.evaluations?.some(
(e: any) => e.evaluatorId?._id === userId || e.evaluatorId === userId
);
return !evaluated;
}
/** Whether ANY assigned staff member hasn't evaluated a given candidate interview yet */
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;
}
isPdfGenerating = signal(false);
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; }
deleteGroupInterview(groupId: string): void {
if (!confirm(`Delete all interviews in group "${groupId}"?`)) return;
const ids = (this.groupedList().find(g => g.groupId === groupId)?.members || []).map((m: any) => m._id);
let done = 0;
for (const id of ids) {
this.quizService.deleteInterview(id).subscribe({ next: () => { done++; if (done === ids.length) { this.loadInterviews(); this.closeDetail(); } } });
}
}
setDecision(interviewId: string, decision: string): void {
this.quizService.setInterviewDecision(interviewId, decision).subscribe({
next: () => this.loadInterviews()
});
}
}
.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;
}
}
<p>individual-interview works!</p> <div class="page-container animate-fade-in">
<div class="content-wrapper">
<div class="page-header">
<div>
<h1>Individual Interviews</h1>
<p class="page-subtitle">Manage one-on-one candidate interview evaluations</p>
</div>
<button class="btn btn-primary" (click)="openCreateModal()">
<span class="material-symbols-rounded">add</span>
New Interview
</button>
</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>
<!-- Create Interview Modal -->
@if (showCreateModal()) {
<div class="modal-overlay" (click)="closeCreateModal()">
<div class="modal-container modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Create Individual Interview</h2>
<button class="icon-btn" (click)="closeCreateModal()"><span class="material-symbols-rounded">close</span></button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">Candidate *</label>
<select class="form-input" [(ngModel)]="newInterview.candidateId">
<option value="">Select candidate</option>
@for (c of candidates(); track c._id) {
<option [value]="c._id">{{ c.name }} ({{ c.email }})</option>
}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label class="form-label">Interviewers</label>
<div class="quiz-select-grid" style="max-height: 120px; overflow-y: auto;">
@for (i of interviewers(); track i._id) {
<label class="quiz-select-item" style="cursor: pointer;">
<input type="checkbox" [value]="i._id" (change)="toggleSelection($event, newInterview.assignedInterviewers)" style="margin-right: 8px;"> {{ i.name }}
</label>
}
@if (interviewers().length === 0) { <p class="text-muted" style="font-size: 12px;">No interviewers found</p> }
</div>
</div>
<div class="form-group" style="flex: 1;">
<label class="form-label">Project Managers</label>
<div class="quiz-select-grid" style="max-height: 120px; overflow-y: auto;">
@for (p of pms(); track p._id) {
<label class="quiz-select-item" style="cursor: pointer;">
<input type="checkbox" [value]="p._id" (change)="toggleSelection($event, newInterview.assignedPMs)" style="margin-right: 8px;"> {{ p.name }}
</label>
}
@if (pms().length === 0) { <p class="text-muted" style="font-size: 12px;">No PMs found</p> }
</div>
</div>
<div class="form-group" style="flex: 1;">
<label class="form-label">HRs</label>
<div class="quiz-select-grid" style="max-height: 120px; overflow-y: auto;">
@for (h of hrs(); track h._id) {
<label class="quiz-select-item" style="cursor: pointer;">
<input type="checkbox" [value]="h._id" (change)="toggleSelection($event, newInterview.assignedHRs)" style="margin-right: 8px;"> {{ h.name }}
</label>
}
@if (hrs().length === 0) { <p class="text-muted" style="font-size: 12px;">No HRs found</p> }
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Position *</label>
<input class="form-input" [(ngModel)]="newInterview.position" placeholder="e.g., Software Intern">
</div>
<div class="form-group">
<label class="form-label">Tech Stack</label>
<input class="form-input" [(ngModel)]="newInterview.techStack" placeholder="e.g., React, Node.js">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Source</label>
<input class="form-input" [(ngModel)]="newInterview.source" placeholder="e.g., LinkedIn, Campus">
</div>
<div class="form-group">
<label class="form-label">Date of Interview</label>
<input class="form-input" type="date" [(ngModel)]="newInterview.dateOfInterview">
</div>
</div>
<!-- Quiz Selection -->
<div class="form-group">
<label class="form-label">Assign Quizzes (optional)</label>
<div class="quiz-select-grid">
@for (q of quizzes(); track q._id) {
<div class="quiz-select-item" [class.selected]="isQuizSelected(q._id)" (click)="toggleQuizSelection(q._id)">
<span class="material-symbols-rounded">{{ isQuizSelected(q._id) ? 'check_box' : 'check_box_outline_blank' }}</span>
<span>{{ q.title }}</span>
</div>
}
@if (quizzes().length === 0) {
<p class="text-muted">No quizzes available</p>
}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeCreateModal()">Cancel</button>
<button class="btn btn-primary" (click)="createInterview()" [disabled]="isSubmitting() || !newInterview.candidateId || !newInterview.position">
@if (isSubmitting()) { <span class="spinner spinner-sm"></span> Creating... } @else { Create Interview }
</button>
</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 (selectedInterview().evaluations?.length > 0) {
<div class="eval-list">
@for (ev of 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>
<!-- Final Decision (admin and hr only) -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') && selectedInterview().status !== 'completed') {
<div class="detail-section decision-section">
<h3 class="detail-section-title">Final Decision</h3>
<div class="decision-buttons">
<button class="btn btn-success" (click)="setDecision('accepted')">
<span class="material-symbols-rounded">check_circle</span> Accept
</button>
<button class="btn btn-warning" (click)="setDecision('on_hold')">
<span class="material-symbols-rounded">pause_circle</span> On Hold
</button>
<button class="btn btn-danger" (click)="setDecision('rejected')">
<span class="material-symbols-rounded">cancel</span> Reject
</button>
<button class="btn btn-outline" (click)="setDecision('2nd_round')">
<span class="material-symbols-rounded">replay</span> 2nd Round
</button>
</div>
</div>
}
</div>
<div class="modal-footer">
@if (authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') {
<button class="btn btn-danger btn-sm" (click)="deleteInterview(selectedInterview()._id)">Delete Interview</button>
}
<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 (ev of selectedInterview().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 } from '@angular/core'; 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({ @Component({
selector: 'app-individual-interview', selector: 'app-individual-interview',
imports: [], standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './individual-interview.html', templateUrl: './individual-interview.html',
styleUrl: './individual-interview.css', styleUrl: './individual-interview.css'
}) })
export class IndividualInterview {} export class IndividualInterviewComponent implements OnInit {
interviews = signal<any[]>([]);
loading = signal(true);
showCreateModal = signal(false);
showDetailModal = signal(false);
selectedInterview = signal<any>(null);
// Create form data
candidates = signal<any[]>([]);
interviewers = signal<any[]>([]);
hrs = signal<any[]>([]);
pms = signal<any[]>([]);
quizzes = signal<any[]>([]);
newInterview = {
candidateId: '',
assignedInterviewers: [] as string[],
assignedHRs: [] as string[],
assignedPMs: [] as string[],
position: '',
techStack: '',
source: '',
dateOfInterview: new Date().toISOString().split('T')[0],
quizIds: [] as string[]
};
// Evaluation form
evalComment = signal('');
evalRecommendation = signal('');
// Stats
stats = signal<any>({ total: 0, pending: 0, completed: 0, accepted: 0, rejected: 0 });
// Filter
filterStatus = signal('');
isSubmitting = signal(false);
constructor(private quizService: QuizService, public authService: AuthService) {}
ngOnInit(): void {
this.loadInterviews();
this.loadStats();
}
loadInterviews(): void {
this.loading.set(true);
const params: any = {};
if (this.filterStatus()) params.status = this.filterStatus();
params.type = 'individual';
this.quizService.getInterviews(params).subscribe({
next: (res) => {
this.interviews.set(res.interviews || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
loadStats(): void {
this.quizService.getInterviewStats().subscribe({
next: (res) => this.stats.set(res)
});
}
openCreateModal(): void {
// Load dropdown data
this.quizService.getInterviewCandidates().subscribe({
next: (res) => this.candidates.set(res.candidates || [])
});
this.quizService.getInterviewers().subscribe({
next: (res) => {
const staff = res.interviewers || [];
this.interviewers.set(staff.filter((s: any) => s.role === 'interviewer'));
this.pms.set(staff.filter((s: any) => s.role === 'pm'));
this.hrs.set(staff.filter((s: any) => s.role === 'hr'));
}
});
this.quizService.getAdminQuizzes().subscribe({
next: (res) => this.quizzes.set(res.quizzes || [])
});
this.newInterview = {
candidateId: '', assignedInterviewers: [], assignedHRs: [], assignedPMs: [], position: '', techStack: '',
source: '', dateOfInterview: new Date().toISOString().split('T')[0], quizIds: []
};
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
}
toggleQuizSelection(quizId: string): void {
const idx = this.newInterview.quizIds.indexOf(quizId);
if (idx >= 0) {
this.newInterview.quizIds.splice(idx, 1);
} else {
this.newInterview.quizIds.push(quizId);
}
}
toggleSelection(event: any, array: string[]): void {
const val = event.target.value;
if (event.target.checked) {
array.push(val);
} else {
const idx = array.indexOf(val);
if (idx >= 0) array.splice(idx, 1);
}
}
isQuizSelected(quizId: string): boolean {
return this.newInterview.quizIds.includes(quizId);
}
createInterview(): void {
if (!this.newInterview.candidateId || !this.newInterview.position) return;
this.isSubmitting.set(true);
this.quizService.createInterview(this.newInterview).subscribe({
next: () => {
this.isSubmitting.set(false);
this.closeCreateModal();
this.loadInterviews();
this.loadStats();
},
error: (err) => {
this.isSubmitting.set(false);
alert(err.error?.message || 'Failed to create interview');
}
});
}
openDetail(interview: any): void {
this.quizService.getInterviewById(interview._id).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.evalComment.set('');
this.evalRecommendation.set('');
this.showDetailModal.set(true);
}
});
}
closeDetail(): void {
this.showDetailModal.set(false);
this.selectedInterview.set(null);
}
submitEvaluation(): void {
const interview = this.selectedInterview();
if (!interview || !this.evalRecommendation()) return;
this.isSubmitting.set(true);
this.quizService.submitEvaluation(interview._id, {
comments: this.evalComment(),
recommendation: this.evalRecommendation()
}).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.evalComment.set('');
this.evalRecommendation.set('');
this.isSubmitting.set(false);
},
error: () => this.isSubmitting.set(false)
});
}
setDecision(decision: string): void {
const interview = this.selectedInterview();
if (!interview) return;
this.quizService.setInterviewDecision(interview._id, decision).subscribe({
next: (res) => {
this.selectedInterview.set(res.interview);
this.loadInterviews();
this.loadStats();
}
});
}
deleteInterview(id: string): void {
if (confirm('Are you sure you want to delete this interview?')) {
this.quizService.deleteInterview(id).subscribe({
next: () => {
this.loadInterviews();
this.loadStats();
this.closeDetail();
}
});
}
}
onFilterChange(): void {
this.loadInterviews();
}
getStatusClass(status: string): string {
switch (status) {
case 'pending': return 'badge-warning';
case 'quiz_phase': return 'badge-info';
case 'coding_phase': return 'badge-info';
case 'evaluation': return 'badge-purple';
case 'completed': return 'badge-success';
default: return '';
}
}
getDecisionClass(decision: string): string {
switch (decision) {
case 'accepted': return 'badge-success';
case 'rejected': return 'badge-danger';
case 'on_hold': return 'badge-warning';
case '2nd_round': return 'badge-info';
default: return 'badge-muted';
}
}
formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
formatDecision(decision: string): string {
const map: any = {
pending: 'Pending', accepted: 'Accepted', rejected: 'Rejected',
on_hold: 'On Hold', '2nd_round': '2nd Round'
};
return map[decision] || decision;
}
hasUserEvaluated(): boolean {
const interview = this.selectedInterview();
if (!interview) return false;
const userId = this.authService.currentUser()?.id;
return interview.evaluations?.some((e: any) => e.evaluatorId?._id === userId);
}
isPdfGenerating = signal(false);
allEvaluationsDone(): boolean {
const iv = this.selectedInterview();
if (!iv) return false;
const numInterviewers = iv.assignedInterviewers?.length || 0;
const numHrs = iv.assignedHRs?.length || 0;
const numPms = iv.assignedPMs?.length || 0;
const totalExpected = numInterviewers + numHrs + numPms;
if (totalExpected === 0) return false;
const numEvaluations = iv.evaluations?.length || 0;
return numEvaluations >= totalExpected;
}
downloadEvaluationPdf(): void {
const iv = this.selectedInterview();
if (!iv) return;
this.isPdfGenerating.set(true);
setTimeout(() => {
window.print();
this.isPdfGenerating.set(false);
}, 500);
}
}
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