Commit 78c91f3e authored by AravindR-K's avatar AravindR-K

feat : group interview added

parent 485cd218
......@@ -178,6 +178,119 @@ router.get('/candidates', authorize('admin', 'hr', 'pm'), async (req, res) => {
}
});
// ============================================================
// @route GET /api/interview/group-members
// @desc Get candidates belonging to a specific group
// @access Admin, HR, PM
// ============================================================
router.get('/group-members', authorize('admin', 'hr', 'pm'), async (req, res) => {
try {
const { group } = req.query;
if (!group) return res.status(400).json({ message: 'Group name is required' });
const candidates = await User.find({ role: 'candidate', group })
.select('name email phoneNumber group')
.sort({ name: 1 });
res.json({ candidates });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route POST /api/interview/group
// @desc Create group interviews for all candidates in a group
// quizSets: [{ quizEntries: [{ quizId }], mode: 'random'|'direct',
// directAssignments: { candidateId: quizId } }]
// @access Admin, HR, PM
// ============================================================
router.post('/group', authorize('admin', 'hr', 'pm'), async (req, res) => {
try {
const {
groupName, assignedInterviewers, assignedHRs, assignedPMs,
position, techStack, source, dateOfInterview, quizSets
} = req.body;
if (!groupName || !position) {
return res.status(400).json({ message: 'Group and position are required' });
}
const candidates = await User.find({ role: 'candidate', group: groupName }).sort({ name: 1 });
if (candidates.length === 0) {
return res.status(400).json({ message: 'No candidates found in this group' });
}
const mainInterviewerId =
assignedInterviewers && assignedInterviewers.length > 0 ? assignedInterviewers[0] : null;
const createdInterviews = [];
for (const candidate of candidates) {
let quizzes = [];
if (quizSets && quizSets.length > 0) {
for (const set of quizSets) {
const validEntries = (set.quizEntries || []).filter(e => e.quizId);
if (validEntries.length === 0) continue;
let assignedQuizId = null;
if (validEntries.length === 1) {
// Only one quiz in this set — assign to everyone
assignedQuizId = validEntries[0].quizId;
} else if (set.mode === 'direct' && set.directAssignments) {
assignedQuizId = set.directAssignments[candidate._id.toString()] || null;
} else {
// Random: pick a random quiz from the set
const pick = validEntries[Math.floor(Math.random() * validEntries.length)];
assignedQuizId = pick.quizId;
}
if (assignedQuizId) {
const quizDoc = await Quiz.findById(assignedQuizId).select('title totalQuestions');
if (quizDoc) {
quizzes.push({
quizId: quizDoc._id,
title: quizDoc.title,
score: null,
totalMarks: quizDoc.totalQuestions,
percentage: null,
completed: false
});
}
}
}
}
const interview = await Interview.create({
candidateId: candidate._id,
interviewerId: mainInterviewerId,
assignedInterviewers: assignedInterviewers || [],
assignedHRs: assignedHRs || [],
assignedPMs: assignedPMs || [],
position,
techStack: techStack || '',
source: source || '',
dateOfInterview: dateOfInterview || new Date(),
quizzes,
status: quizzes.length > 0 ? 'quiz_phase' : 'pending',
type: 'group',
groupId: groupName,
createdBy: req.user._id
});
createdInterviews.push(interview._id);
}
res.status(201).json({
message: `Group interview created for ${candidates.length} candidate(s)`,
count: candidates.length,
interviewIds: createdInterviews
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// ============================================================
// @route GET /api/interview/:id
// @desc Get interview detail
......
/* ═══════════════════════════════════════════════════════
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 {
width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700; color: #fff;
border: 2px solid var(--bg-card); background: #667eea;
transition: transform 0.15s;
}
.candidate-chip:hover { transform: scale(1.15); z-index: 1; }
.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; }
/* ═══════════════════════════════════════════════════════
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; align-items: flex-start; gap: 16px; padding: 16px;
border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-hover);
flex-wrap: wrap; transition: border-color 0.2s;
}
.candidate-row:hover { border-color: rgba(102,126,234,0.35); }
.candidate-row-left { display: flex; align-items: center; gap: 12px; min-width: 180px; }
.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; }
.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; min-width: 280px; }
.candidate-row-right { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
/* 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; } }
/* ═══════════════════════════════════════════════════════
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; }
}
<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 -->
<div class="candidate-chips">
@for (m of g.members; track m._id) {
<span class="candidate-chip" [ngClass]="getStatusClass(m.status)" [title]="m.candidateId?.name + ' — ' + formatStatus(m.status)">
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}
</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">
<div class="candidate-row-left">
<div class="iv-avatar small">{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}</div>
<div>
<div class="cr-name">{{ m.candidateId?.name }}</div>
<div class="cr-email">{{ m.candidateId?.email }}</div>
</div>
</div>
<div class="candidate-row-mid">
<!-- Progress steps mini -->
<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 class="candidate-row-right">
<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>
}
<!-- Quiz scores -->
@if (m.quizzes?.length > 0) {
<div class="quiz-scores-inline">
@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>
}
</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>
}
</div>
</div>
}
</div>
</div>
</div>
<div class="modal-footer">
@if (authService.getUserRole() === 'admin') {
<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>
}
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({
selector: 'app-group-interview',
imports: [],
standalone: true,
imports: [CommonModule, FormsModule],
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[] = [];
// ── Detail modal state ────────────────────────────────────
showDetailModal = signal(false);
selectedGroup = signal<any>(null);
evalComment = signal('');
evalRecommendation = signal('');
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');
}
});
}
// ── Detail modal ──────────────────────────────────────────
openDetail(group: any): void {
this.selectedGroup.set(group);
this.showDetailModal.set(true);
}
closeDetail(): void {
this.showDetailModal.set(false);
this.selectedGroup.set(null);
}
// ── 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()
});
}
}
......@@ -291,4 +291,12 @@
getInterviewCandidates(): Observable<any> {
return this.http.get(`${this.interviewUrl}/candidates`);
}
getGroupMembers(groupName: string): Observable<any> {
return this.http.get(`${this.interviewUrl}/group-members?group=${encodeURIComponent(groupName)}`);
}
createGroupInterview(data: any): Observable<any> {
return this.http.post(`${this.interviewUrl}/group`, data);
}
}
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