Commit b0d0c7a0 authored by AravindR-K's avatar AravindR-K

feat : Hr page updated with all functionality

parent be53edba
This diff is collapsed.
......@@ -82,27 +82,35 @@ export const routes: Routes = [
{
path: 'quizzes',
loadComponent: () => import('./pages/hr/quizzes/quizzes').then(m => m.HRQuizzesComponent)
},
{
path: 'users/:userId/history',
loadComponent: () => import('./pages/hr/user-history/user-history').then(m => m.HRUserHistoryComponent)
},
{
path: 'create-quiz',
loadComponent: () => import('./pages/hr/create-quiz/create-quiz').then(m => m.HRCreateQuizComponent)
},
{
path: 'candidates',
loadComponent: () => import('./pages/hr/candidates/candidates').then(m => m.HRCandidatesComponent)
path: 'users',
loadComponent: () => import('./pages/hr/users/users').then(m => m.Users)
},
{
path: 'manage-groups',
loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent)
},
{
path: 'candidates/:userId/history',
loadComponent: () => import('./pages/hr/candidate-history/candidate-history').then(m => m.HRCandidateHistoryComponent)
loadComponent: () => import('./pages/hr/manage-groups/manage-groups').then(m => m.HRManageGroupsComponent)
},
{
path: 'submissions/:submissionId',
loadComponent: () => import('./pages/hr/submission-detail/submission-detail').then(m => m.HRSubmissionDetailComponent)
},
{
path: 'quiz/:quizId/assign',
loadComponent: () => import('./pages/hr/assign-quiz/assign-quiz').then(m => m.HRAssignQuizComponent)
},
{
path: 'quiz/:quizId/edit',
loadComponent: () => import('./pages/hr/edit-quiz/edit-quiz').then(m => m.HREditQuizComponent)
},
]
},
......
......@@ -37,9 +37,9 @@
<!-- Navigation -->
<nav class="sidebar-nav">
@for (item of navItems(); track item.route) {
@if (item.route === '/admin/users' || item.route === '/hr/candidates') {
@if (item.route === '/admin/users' || item.route === '/hr/users') {
<a class="nav-item" (click)="handleNavClick(item, $event)" href="javascript:void(0)"
[class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/candidates') || router.url.includes('manage-groups')">
[class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/users') || router.url.includes('manage-groups')">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
......@@ -128,7 +128,7 @@
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
@if (authService.getUserRole() === 'admin') {
<a routerLink="/admin/hr-users" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
......
......@@ -48,7 +48,7 @@ export class LayoutComponent {
{ icon: 'dashboard', label: 'Dashboard', route: '/hr/dashboard' },
{ icon: 'quiz', label: 'My Quizzes', route: '/hr/quizzes' },
{ icon: 'add_circle', label: 'Create Quiz', route: '/hr/create-quiz' },
{ icon: 'people', label: 'Candidates', route: '/hr/candidates' },
{ icon: 'people', label: 'Candidates', route: '/hr/users' },
];
case 'candidate':
return [
......@@ -92,7 +92,7 @@ export class LayoutComponent {
}
handleNavClick(item: NavItem, event: Event): void {
if (item.route === '/admin/users' || item.route === '/hr/candidates') {
if (item.route === '/admin/users' || item.route === '/hr/users') {
event.preventDefault();
this.uiService.showManageUsersPopup.set(true);
} else {
......@@ -105,7 +105,7 @@ export class LayoutComponent {
}
getUsersRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/candidates' : '/admin/users';
return this.authService.getUserRole() === 'hr' ? '/hr/users' : '/admin/users';
}
getManageGroupsRoute(): string {
......
......@@ -23,7 +23,7 @@
} @else {
<div class="users-grid">
@for (user of users(); track user._id) {
<a [routerLink]="['/admin/users', user._id, 'history']" class="user-card">
<a [routerLink]="['/admin/user-history/', user._id, 'history']" class="user-card">
<div class="user-card-avatar">{{ user.name?.charAt(0) || '?' }}</div>
<div class="user-card-info">
<div class="name-row">
......
/* ===== Page Container: Match manage-groups exactly ===== */
.page-container { max-width: 1400px; padding: 32px 40px; margin: 0 auto; height: calc(100vh - 64px); display: flex; box-sizing: border-box; }
.split-view { gap: 32px; align-items: stretch; }
.main-workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-y: auto; padding-right: 16px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 8px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 0; }
/* ===== Quiz Info Bar ===== */
.quiz-info-bar { margin-bottom: 30px; }
.quiz-info-row {
display: flex;
gap: 40px;
flex-wrap: wrap;
}
.quiz-info-item {
display: flex;
align-items: center;
gap: 12px;
}
.quiz-info-item > .material-symbols-rounded {
font-size: 20px;
color: var(--accent-primary, #667eea);
background: rgba(102, 126, 234, 0.1);
padding: 10px;
border-radius: 10px;
}
.quiz-info-item div {
display: flex;
flex-direction: column;
gap: 2px;
}
.quiz-info-item small {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.quiz-info-item strong {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
}
/* ===== Section Title ===== */
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
}
.section-title .material-symbols-rounded {
font-size: 22px;
color: var(--accent-primary, #667eea);
}
.count-badge {
background: rgba(102, 126, 234, 0.1);
color: var(--accent-primary, #667eea);
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
margin-left: 4px;
}
/* ===== Available Candidates Pool ===== */
.available-pool {
display: flex;
flex-wrap: wrap;
gap: 16px;
min-height: 120px;
padding: 4px 0;
}
.empty-pool {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 48px 20px;
color: var(--text-muted);
background: var(--bg-hover);
border-radius: 12px;
border: 2px dashed var(--border-color);
text-align: center;
}
.empty-pool .material-symbols-rounded {
font-size: 40px;
margin-bottom: 8px;
opacity: 0.3;
}
.empty-pool p { margin: 0; font-size: 14px; }
/* ===== Candidate Cards (Left Pool) ===== */
.candidate-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: grab;
transition: all 0.2s ease;
min-width: 280px;
flex: 0 1 auto;
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
}
.candidate-card:hover {
border-color: var(--accent-primary, #667eea);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.08);
transform: translateY(-2px);
}
.candidate-card:active {
cursor: grabbing;
}
.candidate-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-input);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.candidate-details {
flex: 1;
min-width: 0;
}
.candidate-details h4 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.candidate-details p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.candidate-meta {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.comfort-tag {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.level-pill {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.level-pill[data-level="beginner"] { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.level-pill[data-level="intermediate"] { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.level-pill[data-level="advanced"] { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
.level-pill[data-level="expert"] { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
.drag-icon {
color: var(--text-muted);
opacity: 0.5;
font-size: 20px;
cursor: grab;
}
/* ===== SIDEBAR: Assigned (matches unassigned-sidebar from manage-groups) ===== */
.assigned-sidebar {
width: 340px;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
overflow: hidden;
background: var(--surface, var(--bg-card));
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 16px);
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(102,126,234,0.05);
}
.sidebar-title {
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-title .material-symbols-rounded {
color: #22c55e;
font-size: 20px;
}
.sidebar-title h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sidebar-badge {
background: #22c55e;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.assigned-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 200px;
}
.empty-assigned {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
opacity: 0.7;
min-height: 200px;
}
.empty-assigned .material-symbols-rounded {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.4;
}
.empty-assigned p {
margin: 0;
font-size: 13px;
}
/* Highlight when dragging over the assigned zone */
.assigned-list.cdk-drop-list-receiving {
background: rgba(102,126,234,0.04);
}
/* ===== Assigned Cards (Sidebar Items — match unassigned-card from manage-groups) ===== */
.assigned-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: grab;
transition: border-color 0.2s, box-shadow 0.2s;
position: relative;
}
.assigned-card:hover {
border-color: var(--accent-primary, #667eea);
box-shadow: 0 4px 12px rgba(102,126,234,0.08);
transform: translateY(-2px);
}
.assigned-card:active {
cursor: grabbing;
}
.assigned-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-input);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.assigned-details {
flex: 1;
min-width: 0;
}
.assigned-details h4 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assigned-details p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===== Sidebar Footer ===== */
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
}
.save-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 24px;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
font-size: 15px;
font-family: inherit;
background: var(--accent-gradient, linear-gradient(135deg, #667eea, #764ba2));
color: #fff;
box-shadow: 0 4px 15px rgba(102,126,234,0.3);
}
.save-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102,126,234,0.4);
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.save-toast {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #22c55e;
justify-content: center;
}
/* ===== CDK Drag & Drop (matches manage-groups exactly) ===== */
.custom-drag-placeholder {
opacity: 0.3;
background: rgba(102,126,234,0.1);
border: 2px dashed var(--accent-primary, #667eea);
border-radius: 12px;
min-height: 60px;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-preview {
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-radius: 12px;
opacity: 0.95;
z-index: 1000 !important;
cursor: grabbing !important;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.available-pool.cdk-drop-list-dragging .candidate-card:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.assigned-list.cdk-drop-list-dragging .assigned-card:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
/* ===== Loading State ===== */
.loading-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); gap: 16px; }
/* ===== Responsive ===== */
@media (max-width: 900px) {
.split-view { flex-direction: column; }
.assigned-sidebar { width: 100%; height: 400px; }
.page-container { height: auto; padding: 20px 16px; }
.quiz-info-row { flex-direction: column; gap: 16px; }
}
<div class="page-container animate-fade-in split-view" cdkDropListGroup>
<div class="main-workspace">
<div class="page-header">
<a routerLink="/hr/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Assign Quiz</h1>
<p class="page-subtitle">Drag candidates to assign them to <strong>{{ quizTitle() }}</strong></p>
</div>
<!-- Quiz Info Badge -->
<div class="quiz-info-bar card card-padding" style="margin-bottom: 30px;">
<div class="quiz-info-row">
<div class="quiz-info-item">
<span class="material-symbols-rounded">quiz</span>
<div>
<small>Quiz</small>
<strong>{{ quizTitle() }}</strong>
</div>
</div>
<div class="quiz-info-item">
<span class="material-symbols-rounded">speed</span>
<div>
<small>Difficulty</small>
<strong>{{ quizDifficulty() | titlecase }}</strong>
</div>
</div>
<div class="quiz-info-item">
<span class="material-symbols-rounded">category</span>
<div>
<small>Category</small>
<strong>{{ quizCategory() || 'General' }}</strong>
</div>
</div>
<div class="quiz-info-item">
<span class="material-symbols-rounded">filter_list</span>
<div>
<small>Filter Criteria</small>
<strong>{{ getFilterDescription() }}</strong>
</div>
</div>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p>Loading candidates...</p>
</div>
} @else {
<!-- Available Candidates Pool -->
<h2 class="section-title">
<span class="material-symbols-rounded">person_search</span>
Eligible Candidates
<span class="count-badge">{{ availableCandidates.length }}</span>
</h2>
<div class="available-pool" cdkDropList [cdkDropListData]="availableCandidates" (cdkDropListDropped)="drop($event)">
@if (availableCandidates.length === 0) {
<div class="empty-pool">
<span class="material-symbols-rounded">done_all</span>
<p>All matching candidates have been assigned!</p>
</div>
}
@for (candidate of availableCandidates; track candidate._id) {
<div class="candidate-card" cdkDrag [cdkDragData]="candidate">
<div class="candidate-avatar">{{ candidate.name.charAt(0).toUpperCase() }}</div>
<div class="candidate-details">
<h4>{{ candidate.name }}</h4>
<p>{{ candidate.email }}</p>
</div>
<div class="candidate-meta">
@if (candidate.matchedProficiency) {
<span class="level-pill" [attr.data-level]="candidate.matchedProficiency">{{ candidate.matchedProficiency | titlecase }}</span>
<span class="comfort-tag">{{ candidate.matchedComfort }}%</span>
} @else {
<span class="level-pill" [attr.data-level]="candidate.level">{{ candidate.level | titlecase }}</span>
}
</div>
<span class="material-symbols-rounded drag-icon">drag_indicator</span>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
}
</div>
<!-- Sidebar: Assigned Candidates -->
<aside class="assigned-sidebar card">
<div class="sidebar-header">
<div class="sidebar-title">
<span class="material-symbols-rounded">how_to_reg</span>
<h2>Assigned</h2>
</div>
<div class="sidebar-badge">{{ assignedCandidates.length }}</div>
</div>
<div class="assigned-list" cdkDropList [cdkDropListData]="assignedCandidates" (cdkDropListDropped)="drop($event)">
@if (assignedCandidates.length === 0) {
<div class="empty-assigned">
<span class="material-symbols-rounded">move_down</span>
<p>Drag candidates here to assign</p>
</div>
}
@for (candidate of assignedCandidates; track candidate._id) {
<div class="assigned-card" cdkDrag [cdkDragData]="candidate">
<div class="assigned-avatar">{{ candidate.name.charAt(0).toUpperCase() }}</div>
<div class="assigned-details">
<h4>{{ candidate.name }}</h4>
<p>{{ candidate.email }}</p>
</div>
<span class="material-symbols-rounded drag-icon">drag_indicator</span>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
<!-- Save Button -->
<div class="sidebar-footer">
@if (successMessage()) {
<span class="save-toast">
<span class="material-symbols-rounded">check_circle</span>
{{ successMessage() }}
</span>
}
<button class="btn btn-primary save-btn" (click)="saveAssignments()" [disabled]="isSaving()">
@if (isSaving()) {
<span class="spinner"></span> Saving...
} @else {
<span class="material-symbols-rounded">save</span> Save Assignments
}
</button>
</div>
</aside>
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CdkDragDrop, moveItemInArray, transferArrayItem, DragDropModule } from '@angular/cdk/drag-drop';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-assign-quiz',
imports: [CommonModule, RouterLink, FormsModule, DragDropModule],
templateUrl: './assign-quiz.html',
styleUrl: './assign-quiz.css',
})
export class HRAssignQuizComponent {
quizId = '';
quizTitle = signal('');
quizDifficulty = signal('');
quizCategory = signal('');
// Use plain arrays for CDK drag-drop (signals break mutation)
availableCandidates: any[] = [];
assignedCandidates: any[] = [];
loading = signal(true);
isSaving = signal(false);
successMessage = signal('');
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit() {
this.quizId = this.route.snapshot.paramMap.get('quizId') || '';
if (this.quizId) {
this.loadCandidates();
}
}
loadCandidates() {
this.loading.set(true);
this.quizService.getHRAssignCandidates(this.quizId).subscribe({
next: (res) => {
this.quizTitle.set(res.quizTitle);
this.quizDifficulty.set(res.quizDifficulty || 'General');
this.quizCategory.set(res.quizCategory || '');
// Only show filtered (recommended) candidates
const filtered = res.filteredCandidates || [];
const assigned: any[] = [];
const available: any[] = [];
filtered.forEach((c: any) => {
if (res.assignedIds.includes(c._id)) {
assigned.push(c);
} else {
available.push(c);
}
});
this.assignedCandidates = assigned;
this.availableCandidates = available;
this.loading.set(false);
},
error: (err) => {
console.error(err);
this.loading.set(false);
}
});
}
drop(event: CdkDragDrop<any[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex,
);
}
}
saveAssignments() {
this.isSaving.set(true);
const assignedIds = this.assignedCandidates.map(c => c._id);
console.log('assignedCandidates:', this.assignedCandidates);
console.log('assignedIds:', assignedIds);
this.quizService.assignHRQuiz(this.quizId, { assignees: assignedIds }).subscribe({
next: () => {
this.isSaving.set(false);
this.successMessage.set('Assignments saved successfully!');
setTimeout(() => this.successMessage.set(''), 3000);
},
error: () => {
this.isSaving.set(false);
alert('Failed to update assignments');
}
});
}
getFilterDescription(): string {
const d = this.quizDifficulty().toLowerCase();
const cat = this.quizCategory() || 'Any';
let levelDesc = 'All levels';
if (d === 'easy') levelDesc = 'Beginner (0-25% comfort)';
if (d === 'medium') levelDesc = 'Intermediate & Advanced (26-75%)';
if (d === 'hard') levelDesc = 'Expert (76-100% comfort)';
return `${levelDesc} in "${cat}"`;
}
}
.page-container { padding: 32px 40px; max-width: 1100px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; margin-bottom: 20px; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.user-header { display: flex; align-items: center; gap: 16px; margin-bottom: 32px; }
.user-avatar {
width: 56px; height: 56px; border-radius: var(--radius-lg);
background: var(--accent-gradient); display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; font-size: 24px;
}
.user-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0; }
.user-email { font-size: 14px; color: var(--text-muted); margin: 2px 0 0; }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
.percent-bar { width: 80px; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 8px; }
.percent-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.percent-fill.good { background: var(--success); }
.percent-fill.avg { background: var(--warning); }
.percent-fill.poor { background: var(--danger); }
.percent-text { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
<div class="page-container animate-fade-in">
<a routerLink="/hr/candidates" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Candidates
</a>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading history...</p></div>
} @else {
@if (user()) {
<div class="user-header">
<div class="user-avatar">{{ user().name?.charAt(0)?.toUpperCase() }}</div>
<div>
<h1>{{ user().name }}</h1>
<p class="user-email">{{ user().email }}</p>
</div>
</div>
}
<h2 class="section-title">Quiz History</h2>
@if (submissions().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">assignment</span>
<h3>No quizzes taken</h3>
<p>This candidate hasn't taken any quizzes yet.</p>
</div>
} @else {
<div class="table-container">
<table>
<thead>
<tr>
<th>Quiz</th>
<th>Score</th>
<th>Percentage</th>
<th>Time</th>
<th>Date</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@for (sub of submissions(); track sub._id) {
<tr>
<td><strong>{{ sub.quizId?.title || 'Deleted Quiz' }}</strong></td>
<td><span class="badge badge-primary">{{ sub.score }}/{{ sub.totalMarks }}</span></td>
<td>
<div class="percent-bar">
<div class="percent-fill" [style.width.%]="sub.percentage"
[class.good]="sub.percentage >= 70"
[class.avg]="sub.percentage >= 40 && sub.percentage < 70"
[class.poor]="sub.percentage < 40"></div>
</div>
<span class="percent-text">{{ sub.percentage }}%</span>
</td>
<td>{{ formatTime(sub.timeTaken) }}</td>
<td>{{ sub.submittedAt | date:'mediumDate' }}</td>
<td><a [routerLink]="['/hr/submissions', sub._id]" class="btn btn-outline btn-sm">View</a></td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-candidate-history',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './candidate-history.html',
styleUrl: './candidate-history.css'
})
export class HRCandidateHistoryComponent implements OnInit {
userId = '';
user = signal<any>(null);
submissions = signal<any[]>([]);
loading = signal(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit(): void {
this.userId = this.route.snapshot.params['userId'];
this.quizService.getHRCandidateHistory(this.userId).subscribe({
next: (res) => {
this.user.set(res.user);
this.submissions.set(res.submissions);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
}
.page-container { padding: 32px 40px; max-width: 1200px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.candidates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.candidate-card {
display: flex; align-items: center; gap: 16px; padding: 20px;
text-decoration: none; cursor: pointer;
}
.candidate-avatar {
width: 48px; height: 48px; border-radius: var(--radius-md);
background: var(--accent-gradient); display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; font-size: 20px; flex-shrink: 0;
}
.candidate-info { flex: 1; min-width: 0; }
.candidate-info h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 2px; }
.candidate-info p { font-size: 13px; color: var(--text-secondary); margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.candidate-meta { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
.arrow-icon { color: var(--text-muted); font-size: 22px; transition: transform 0.2s; }
.candidate-card:hover .arrow-icon { transform: translateX(4px); color: var(--accent-primary); }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.candidates-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<h1>Candidates</h1>
<p class="page-subtitle">View all candidates and their quiz history</p>
</div>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading candidates...</p></div>
} @else if (candidates().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">people</span>
<h3>No candidates found</h3>
<p>No candidates have registered yet.</p>
</div>
} @else {
<div class="candidates-grid stagger-children">
@for (user of candidates(); track user._id) {
<a [routerLink]="['/hr/candidates', user._id, 'history']" class="card card-hover candidate-card">
<div class="candidate-avatar">{{ user.name?.charAt(0)?.toUpperCase() || '?' }}</div>
<div class="candidate-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
<span class="candidate-meta">
@if (user.group) { <span class="badge badge-primary">{{ user.group }}</span> }
Joined {{ user.createdAt | date:'mediumDate' }}
</span>
</div>
<span class="material-symbols-rounded arrow-icon">chevron_right</span>
</a>
}
</div>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-hr-candidates',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './candidates.html',
styleUrl: './candidates.css'
})
export class HRCandidatesComponent implements OnInit {
candidates = signal<any[]>([]);
loading = signal(true);
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.quizService.getHRCandidates().subscribe({
next: (res) => { this.candidates.set(res.candidates || res.users || []); this.loading.set(false); },
error: () => this.loading.set(false)
});
}
}
.page-container { padding: 32px 40px; max-width: 800px; }
.page-container { padding: 32px 40px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 4px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; transition: color 0.15s; }
.back-link:hover { color: var(--accent-primary); }
.back-link .material-symbols-rounded { font-size: 18px; }
/* ============ MODE TOGGLE ============ */
.mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.mode-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 16px;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.25s ease;
color: var(--text-secondary);
box-shadow: var(--shadow-card);
}
.mode-btn:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.mode-btn.active {
border-color: var(--accent-primary);
background: var(--accent-primary-light);
color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(var(--accent-primary-rgb), 0.1);
}
.mode-btn .material-symbols-rounded {
font-size: 32px;
transition: transform 0.2s;
}
.mode-btn.active .material-symbols-rounded {
transform: scale(1.15);
}
.mode-label {
font-size: 16px;
font-weight: 700;
}
.mode-desc {
font-size: 12px;
color: var(--text-muted);
}
.mode-btn.active .mode-desc {
color: var(--accent-primary);
opacity: 0.7;
}
/* ============ FORM ============ */
.form-card { margin-bottom: 32px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; }
.form-textarea {
resize: vertical;
min-height: 100px;
line-height: 1.6;
}
.file-upload {
display: flex; align-items: center; gap: 12px; padding: 20px;
display: flex; align-items: center; gap: 12px; padding: 24px;
background: var(--bg-input); border: 2px dashed var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all 0.2s;
}
......@@ -20,7 +89,117 @@
.file-name { font-size: 14px; color: var(--text-primary); font-weight: 500; }
.file-placeholder { font-size: 14px; color: var(--text-muted); }
.format-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
/* ============ AI SECTION ============ */
.ai-section {
padding: 24px;
background: rgba(var(--accent-primary-rgb), 0.04);
border: 1px solid rgba(var(--accent-primary-rgb), 0.12);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: 20px;
}
.ai-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--accent-gradient);
color: #fff;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
width: fit-content;
}
.ai-badge .material-symbols-rounded {
font-size: 16px;
}
.ai-loading-text {
font-size: 14px;
animation: pulse-text 1.5s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ============ SUBMIT ============ */
.submit-btn {
width: 100%;
margin-top: 4px;
}
/* ============ ASSIGNMENT ============ */
.assignment-box {
margin-top: 12px;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
margin-top: 8px;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.15s ease;
}
.list-item:last-child { border-bottom: none; }
.list-item:hover { background: var(--bg-hover); }
.list-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
cursor: pointer;
}
.item-details { display: flex; flex-direction: column; }
.item-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.item-sub { font-size: 12px; color: var(--text-muted); }
.empty-list {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row { flex-direction: column; }
.mode-toggle { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<a routerLink="/hr/quizzes" class="back-link">
<a routerLink="/admin/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Create New Quiz</h1>
<p class="page-subtitle">Upload an Excel file with questions to create a quiz</p>
<p class="page-subtitle">Choose a method to create your quiz</p>
</div>
<!-- Mode Toggle -->
<div class="mode-toggle">
<button class="mode-btn" [class.active]="mode() === 'excel'" (click)="setMode('excel')">
<span class="material-symbols-rounded">upload_file</span>
<span class="mode-label">Excel Upload</span>
<span class="mode-desc">Upload a spreadsheet</span>
</button>
<button class="mode-btn" [class.active]="mode() === 'ai'" (click)="setMode('ai')">
<span class="material-symbols-rounded">auto_awesome</span>
<span class="mode-label">AI Generate</span>
<span class="mode-desc">Let AI create questions</span>
</button>
</div>
@if (error()) {
......@@ -16,45 +30,144 @@
<div class="card card-padding form-card">
<form (ngSubmit)="onSubmit()" class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title *</label>
<input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div>
<div class="form-row">
<!-- ============ EXCEL MODE ============ -->
@if (mode() === 'excel') {
<div class="form-group">
<label class="form-label">Time Limit (minutes) *</label>
<input class="form-input" type="number" [(ngModel)]="timer" name="timer" min="1">
<label class="form-label">Quiz Title *</label>
<input class="form-input" [(ngModel)]="title" name="title" placeholder="e.g. JavaScript Basics">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
<div class="form-row">
<div class="form-group">
<label class="form-label">Time Limit (minutes) *</label>
<input class="form-input" type="number" [(ngModel)]="timer" name="timer" min="1">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" name="category" placeholder="e.g. Java, Angular, Data Structures">
<input class="form-input" [(ngModel)]="category" name="categoryExcel" placeholder="e.g. Java, Angular, Data Structures">
</div>
</div>
<div class="form-group">
<label class="form-label">Questions File (Excel) *</label>
<div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden>
<span class="material-symbols-rounded upload-icon">upload_file</span>
@if (fileName()) {
<span class="file-name">{{ fileName() }}</span>
} @else {
<span class="file-placeholder">Click to select an Excel file</span>
}
<div class="form-group">
<label class="form-label">Questions File (Excel) *</label>
<div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden>
<span class="material-symbols-rounded upload-icon">upload_file</span>
@if (fileName()) {
<span class="file-name">{{ fileName() }}</span>
} @else {
<span class="file-placeholder">Click to select an Excel file</span>
}
</div>
<p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">info</span>
Format: Question | Option1 | Option2 | Option3 | Option4 | Correct
</p>
</div>
}
<!-- ============ AI MODE ============ -->
@if (mode() === 'ai') {
<div class="ai-section animate-fade-in">
<div class="ai-badge">
<span class="material-symbols-rounded">auto_awesome</span>
AI-Powered Generation
</div>
<div class="form-group">
<label class="form-label">Describe Your Quiz *</label>
<textarea class="form-input form-textarea" [(ngModel)]="category" name="category" rows="4"
placeholder="e.g. Generate a quiz on Operating Systems covering process scheduling, memory management, and file systems. Include 10 questions that a student can answer in 15 minutes."></textarea>
<p class="format-hint">
<span class="material-symbols-rounded" style="font-size:14px">lightbulb</span>
Tip: Mention the topic, number of questions, and time limit in your prompt. AI will auto-generate the quiz title, timer, and questions.
</p>
</div>
<div class="form-group">
<label class="form-label">Difficulty Level</label>
<select class="form-select" [(ngModel)]="difficulty" name="difficultyAi">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
}
<!-- Assignment (shared) -->
<div class="form-group">
<label class="form-label">Assign To</label>
<select class="form-select" [(ngModel)]="assignmentType" name="assignmentType">
<option value="none">None (Assign Later)</option>
<option value="all">All Candidates</option>
<option value="users">Individual People</option>
<option value="groups">Specific Groups</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-lg" [disabled]="loading()" style="width: 100%;">
@if (loading()) {
@if (assignmentType === 'users') {
<div class="form-group assignment-box">
<label class="form-label">Search Candidates</label>
<input class="form-input" type="text" [(ngModel)]="userSearchQuery" name="userSearchQuery"
(input)="onUserSearch()" placeholder="Search by name or email...">
<div class="list-container mt-2">
@for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers.includes(user._id)"
(change)="toggleUserSelection(user._id)">
<div class="item-details">
<span class="item-name">{{ user.name }}</span>
<span class="item-sub">{{ user.email }}</span>
</div>
</div>
}
@if (filteredUsers.length === 0) {
<div class="empty-list">No candidates found</div>
}
</div>
</div>
}
@if (assignmentType === 'groups') {
<div class="form-group assignment-box">
<label class="form-label">Select Groups</label>
<div class="list-container">
@for (group of availableGroups; track group) {
<div class="list-item" (click)="toggleGroupSelection(group)">
<input type="checkbox" [checked]="selectedGroups.includes(group)"
(change)="toggleGroupSelection(group)">
<div class="item-details">
<span class="item-name">{{ group }}</span>
</div>
</div>
}
@if (availableGroups.length === 0) {
<div class="empty-list">No groups found</div>
}
</div>
</div>
}
<!-- Submit -->
<button type="submit" class="btn btn-primary btn-lg submit-btn" [disabled]="loading()">
@if (loading() && mode() === 'ai') {
<div class="spinner"></div>
<span class="ai-loading-text">AI is generating your quiz...</span>
} @else if (loading()) {
<div class="spinner"></div> Creating...
} @else if (mode() === 'ai') {
<span class="material-symbols-rounded">auto_awesome</span> Generate Quiz with AI
} @else {
<span class="material-symbols-rounded">add_circle</span> Create Quiz
}
......
import { Component, signal } from '@angular/core';
import { Component, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
......@@ -12,19 +12,90 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './create-quiz.css'
})
export class HRCreateQuizComponent {
// Mode toggle: 'excel' or 'ai'
mode = signal<'excel' | 'ai'>('excel');
// Excel mode fields
title = '';
timer = 30;
category = '';
difficulty = 'medium';
category = '';
selectedFile: File | null = null;
fileName = signal('');
// Assignment
assignmentType = 'all';
availableUsers: any[] = [];
filteredUsers: any[] = [];
availableGroups: string[] = [];
selectedUsers: string[] = [];
selectedGroups: string[] = [];
userSearchQuery = '';
// State
loading = signal(false);
success = signal('');
error = signal('');
constructor(private quizService: QuizService, private router: Router) {}
ngOnInit(): void {
this.fetchUsersAndGroups();
}
setMode(mode: 'excel' | 'ai'): void {
this.mode.set(mode);
this.error.set('');
this.success.set('');
}
fetchUsersAndGroups(): void {
this.quizService.getHRCandidates().subscribe({
next: (res) => {
this.availableUsers = res.users || [];
this.filteredUsers = [...this.availableUsers];
},
error: (err) => console.error('Failed to fetch users', err)
});
this.quizService.getHRGroups().subscribe({
next: (res) => {
this.availableGroups = res.groups || [];
},
error: (err) => console.error('Failed to fetch groups', err)
});
}
onUserSearch(): void {
if (!this.userSearchQuery.trim()) {
this.filteredUsers = [...this.availableUsers];
} else {
const q = this.userSearchQuery.toLowerCase();
this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
}
}
toggleUserSelection(userId: string): void {
const index = this.selectedUsers.indexOf(userId);
if (index > -1) {
this.selectedUsers.splice(index, 1);
} else {
this.selectedUsers.push(userId);
}
}
toggleGroupSelection(group: string): void {
const index = this.selectedGroups.indexOf(group);
if (index > -1) {
this.selectedGroups.splice(index, 1);
} else {
this.selectedGroups.push(group);
}
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
......@@ -34,6 +105,14 @@ export class HRCreateQuizComponent {
}
onSubmit(): void {
if (this.mode() === 'excel') {
this.submitExcel();
} else {
this.submitAI();
}
}
private submitExcel(): void {
if (!this.title.trim()) { this.error.set('Please enter a quiz title'); return; }
if (!this.timer || this.timer < 1) { this.error.set('Timer must be at least 1 minute'); return; }
if (!this.selectedFile) { this.error.set('Please upload an Excel file'); return; }
......@@ -46,8 +125,10 @@ export class HRCreateQuizComponent {
formData.append('title', this.title);
formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile);
if (this.category) formData.append('category', this.category);
if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.category) formData.append('category', this.category);
this.appendAssignment(formData);
this.quizService.createHRQuiz(formData).subscribe({
next: (res) => {
......@@ -61,4 +142,59 @@ export class HRCreateQuizComponent {
}
});
}
private submitAI(): void {
if (!this.category.trim()) { this.error.set('Please describe your quiz topic/category'); return; }
this.loading.set(true);
this.error.set('');
this.success.set('');
const data: any = {
prompt: this.category,
difficulty: this.difficulty
};
if (this.assignmentType === 'all') {
data.assignToAll = true;
} else if (this.assignmentType === 'users') {
data.assignToAll = false;
data.assignees = this.selectedUsers;
} else if (this.assignmentType === 'groups') {
data.assignToAll = false;
data.assignedGroups = this.selectedGroups;
} else if (this.assignmentType === 'none') {
data.assignToAll = false;
data.assignees = [];
data.assignedGroups = [];
}
this.quizService.generateHRAIQuiz(data).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`AI Quiz "${res.quiz.title}" generated with ${res.quiz.totalQuestions} questions!`);
setTimeout(() => this.router.navigate(['/hr/quizzes']), 2000);
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'AI generation failed');
}
});
}
private appendAssignment(formData: FormData): void {
if (this.assignmentType === 'all') {
formData.append('assignToAll', 'true');
} else if (this.assignmentType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers));
} else if (this.assignmentType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups));
} else if (this.assignmentType === 'none') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify([]));
formData.append('assignedGroups', JSON.stringify([]));
}
}
}
.page { padding: 32px; max-width: 1200px; margin: 0 auto; }
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 24px; font-weight: 700; color: var(--text-primary); margin: 0 0 4px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin: 0; }
.loading-center { display: flex; align-items: center; justify-content: center; padding: 80px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { display: flex; align-items: center; gap: 16px; }
.stat-ic { font-size: 32px; }
.stat-ic.blue { color: var(--accent-primary); }
.stat-ic.green { color: var(--success); }
.stat-ic.orange { color: var(--warning); }
.stat-body { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.stat-label { font-size: 13px; color: var(--text-muted); }
.actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.action-card { display: flex; align-items: center; gap: 16px; text-decoration: none; color: inherit; }
.action-icon { font-size: 28px; color: var(--accent-primary); }
.action-info { flex: 1; }
.action-info h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 4px; }
.action-info p { font-size: 13px; color: var(--text-muted); margin: 0; }
.arrow { color: var(--text-muted); font-size: 20px; transition: transform 0.2s; }
.action-card:hover .arrow { transform: translateX(4px); color: var(--accent-primary); }
@media (max-width: 768px) { .page { padding: 20px; } .actions-grid { grid-template-columns: 1fr; } }
/* Admin Dashboard */
.page {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
/* Loading */
.loading-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
gap: 16px;
}
.loading-text {
font-size: 14px;
color: var(--text-muted);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-wrap .material-symbols-rounded {
font-size: 24px;
color: #fff;
}
.stat-icon-wrap.blue { background: linear-gradient(135deg, #4f6ef7, #3b5bdb); }
.stat-icon-wrap.purple { background: linear-gradient(135deg, #7c5cfc, #6033e0); }
.stat-icon-wrap.green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.stat-icon-wrap.orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.stat-icon-wrap.teal { background: linear-gradient(135deg, #14b8a6, #0d9488); }
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
/* Sections */
.section {
margin-bottom: 32px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
/* Quick Actions */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.action-card {
display: flex;
align-items: center;
gap: 16px;
text-decoration: none;
color: inherit;
}
.action-icon {
font-size: 28px;
color: var(--accent-primary);
flex-shrink: 0;
}
.action-info {
flex: 1;
}
.action-info h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.action-info p {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.action-arrow {
color: var(--text-muted);
font-size: 20px;
transition: transform 0.2s;
}
.action-card:hover .action-arrow {
transform: translateX(4px);
color: var(--accent-primary);
}
/* Table helpers */
.user-cell {
display: flex;
flex-direction: column;
}
.user-cell-name {
font-weight: 600;
font-size: 14px;
}
.user-cell-email {
font-size: 12px;
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.page { padding: 20px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.actions-grid { grid-template-columns: 1fr; }
}
<!-- hr Dashboard -->
<div class="page animate-fade-in">
<div class="page-header"><h1>HR Dashboard</h1><p class="page-subtitle">Manage quizzes and review candidates</p></div>
@if (loading()) { <div class="loading-center"><div class="spinner spinner-lg"></div></div> }
@else if (stats()) {
<div class="page-header">
<div>
<h1>Dashboard</h1>
<p class="page-subtitle">Overview of your assessment platform</p>
</div>
</div>
@if (loading()) {
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p class="loading-text">Loading statistics...</p>
</div>
} @else if (stats()) {
<!-- Stats Cards -->
<div class="stats-grid stagger-children">
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic blue">quiz</span><div class="stat-body"><span class="stat-value">{{ stats().myQuizzes }}</span><span class="stat-label">My Quizzes</span></div></div>
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic green">people</span><div class="stat-body"><span class="stat-value">{{ stats().totalCandidates }}</span><span class="stat-label">Candidates</span></div></div>
<div class="stat-card card card-padding"><span class="material-symbols-rounded stat-ic orange">assignment_turned_in</span><div class="stat-body"><span class="stat-value">{{ stats().totalSubmissions }}</span><span class="stat-label">Submissions</span></div></div>
<div class="stat-card">
<div class="stat-icon-wrap blue">
<span class="material-symbols-rounded">group</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalUsers }}</span>
<span class="stat-label">Candidates</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap green">
<span class="material-symbols-rounded">quiz</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalQuizzes }}</span>
<span class="stat-label">Quizzes</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap orange">
<span class="material-symbols-rounded">assignment_turned_in</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().totalSubmissions }}</span>
<span class="stat-label">Submissions</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap teal">
<span class="material-symbols-rounded">circle</span>
</div>
<div class="stat-body">
<span class="stat-value">{{ stats().onlineUsers }}</span>
<span class="stat-label">Online Now</span>
</div>
</div>
</div>
<div class="actions-grid">
<a routerLink="/hr/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span><div class="action-info"><h3>My Quizzes</h3><p>View and manage your quizzes</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a>
<a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span><div class="action-info"><h3>Create Quiz</h3><p>Upload or generate new assessments</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a>
<a routerLink="/hr/candidates" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">people</span><div class="action-info"><h3>Candidates</h3><p>View candidate results and history</p></div><span class="material-symbols-rounded arrow">arrow_forward</span></a>
<!-- Quick Actions -->
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
<p>View candidates, HR users, and online status</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/quizzes" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">quiz</span>
<div class="action-info">
<h3>Manage Quizzes</h3>
<p>View, edit, assign, and delete quizzes</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
<a routerLink="/hr/create-quiz" class="action-card card card-hover card-padding">
<span class="material-symbols-rounded action-icon">add_circle</span>
<div class="action-info">
<h3>Create Quiz</h3>
<p>Upload Excel or generate with AI</p>
</div>
<span class="material-symbols-rounded action-arrow">arrow_forward</span>
</a>
</div>
</div>
<!-- Recent Submissions -->
@if (recentSubmissions().length > 0) {
<div class="section">
<h2 class="section-title">Recent Submissions</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Candidate</th>
<th>Quiz</th>
<th>Score</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (sub of recentSubmissions(); track sub._id) {
<tr>
<td>
<div class="user-cell">
<span class="user-cell-name">{{ sub.studentId?.name }}</span>
<span class="user-cell-email">{{ sub.studentId?.email }}</span>
</div>
</td>
<td>{{ sub.quizId?.title }}</td>
<td>
<span class="badge" [ngClass]="{
'badge-success': sub.percentage >= 70,
'badge-warning': sub.percentage >= 40 && sub.percentage < 70,
'badge-danger': sub.percentage < 40
}">{{ sub.percentage }}%</span>
</td>
<td class="text-muted">{{ sub.submittedAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
......@@ -2,6 +2,7 @@ import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
import { UiService } from '../../../services/ui.service';
@Component({
selector: 'app-hr-dashboard',
......@@ -12,14 +13,23 @@ import { QuizService } from '../../../services/quiz.service';
})
export class HRDashboardComponent implements OnInit {
stats = signal<any>(null);
recentSubmissions = signal<any[]>([]);
loading = signal(true);
constructor(private quizService: QuizService) {}
constructor(private quizService: QuizService, private uiService: UiService) {}
ngOnInit(): void {
this.quizService.getHRStats().subscribe({
next: (res) => { this.stats.set(res.stats); this.loading.set(false); },
next: (res) => {
this.stats.set(res.stats);
this.recentSubmissions.set(res.recentSubmissions || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true);
}
}
.page-container { padding: 32px 40px; max-width: 900px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 8px 0 0; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.form-card { margin-bottom: 24px; }
.quiz-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-row { display: flex; gap: 16px; }
.questions-header { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 16px; }
.questions-header h2 { font-size: 18px; font-weight: 600; color: var(--text-primary); }
.question-card { margin-bottom: 16px; display: flex; flex-direction: column; gap: 14px; }
.question-header { display: flex; align-items: center; justify-content: space-between; }
.q-number { font-size: 13px; font-weight: 700; color: var(--accent-primary); background: var(--accent-primary-light); padding: 4px 12px; border-radius: var(--radius-full); }
.options-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.option-input-wrap { display: flex; align-items: center; gap: 8px; }
.option-letter { font-size: 12px; font-weight: 700; color: var(--text-muted); width: 20px; text-align: center; flex-shrink: 0; }
.save-btn { width: 100%; margin-top: 24px; }
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.form-row, .options-grid { flex-direction: column; grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<a routerLink="/admin/quizzes" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Quizzes
</a>
<h1>Edit Quiz</h1>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
@if (locked()) {
<div class="alert alert-warning"><span class="material-symbols-rounded">lock</span> This quiz has been attempted. Questions cannot be modified.</div>
}
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading quiz...</p></div>
} @else {
<div class="card card-padding form-card">
<div class="quiz-form">
<div class="form-group">
<label class="form-label">Quiz Title</label>
<input class="form-input" [(ngModel)]="title" placeholder="Quiz title">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Timer (min)</label>
<input class="form-input" type="number" [(ngModel)]="timer" min="1">
</div>
<div class="form-group">
<label class="form-label">Difficulty</label>
<select class="form-select" [(ngModel)]="difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input class="form-input" [(ngModel)]="category" placeholder="e.g. Java, Angular, Data Structures">
</div>
</div>
</div>
@if (!locked()) {
<div class="questions-header">
<h2>Questions ({{ questions().length }})</h2>
<button class="btn btn-outline btn-sm" (click)="addQuestion()">
<span class="material-symbols-rounded">add</span> Add Question
</button>
</div>
@for (q of questions(); track $index; let i = $index) {
<div class="card card-padding question-card">
<div class="question-header">
<span class="q-number">Q{{ i + 1 }}</span>
<button class="btn btn-ghost btn-sm" (click)="removeQuestion(i)">
<span class="material-symbols-rounded">delete</span>
</button>
</div>
<div class="form-group">
<input class="form-input" [ngModel]="q.question" (ngModelChange)="updateQuestion(i, 'question', $event)" placeholder="Question text">
</div>
<div class="options-grid">
@for (opt of q.options; track $index; let j = $index) {
<div class="option-input-wrap">
<span class="option-letter">{{ ['A','B','C','D'][j] }}</span>
<input class="form-input" [ngModel]="opt" (ngModelChange)="updateOption(i, j, $event)" placeholder="Option {{ j+1 }}">
</div>
}
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Correct Answer</label>
<input class="form-input" [ngModel]="q.correctAnswer" (ngModelChange)="updateQuestion(i, 'correctAnswer', $event)" placeholder="Correct answer text">
</div>
<div class="form-group" style="max-width: 120px;">
<label class="form-label">Marks</label>
<input class="form-input" type="number" [ngModel]="q.marks" (ngModelChange)="updateQuestion(i, 'marks', $event)" min="1">
</div>
</div>
</div>
}
}
<button class="btn btn-primary btn-lg save-btn" (click)="onSave()" [disabled]="saving()">
@if (saving()) {
<div class="spinner"></div> Saving...
} @else {
<span class="material-symbols-rounded">save</span> Save Changes
}
</button>
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-edit-quiz',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './edit-quiz.html',
styleUrl: './edit-quiz.css',
})
export class HREditQuizComponent {
quizId = '';
title = '';
timer = 30;
category = '';
difficulty = 'medium';
questions = signal<any[]>([]);
loading = signal(true);
saving = signal(false);
success = signal('');
error = signal('');
locked = signal(false);
constructor(
private route: ActivatedRoute,
private router: Router,
private quizService: QuizService
) {}
ngOnInit(): void {
this.quizId = this.route.snapshot.params['quizId'];
this.loadQuiz();
}
loadQuiz(): void {
this.quizService.getHRQuiz(this.quizId).subscribe({
next: (res) => {
const q = res.quiz;
this.title = q.title;
this.timer = q.timer;
this.category = q.category || '';
this.difficulty = q.difficulty || 'medium';
this.questions.set(q.questions || []);
this.locked.set(res.hasAttempts || false);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to load quiz');
this.loading.set(false);
}
});
}
onSave(): void {
if (!this.title.trim()) {
this.error.set('Title is required');
return;
}
this.saving.set(true);
this.error.set('');
// 🔥 Format questions correctly
const formattedQuestions = this.questions().map(q => {
const options = q.options;
const correctIndex = options.findIndex(
(opt: string) => opt == q.correctAnswer
);
return {
question: q.question,
options,
correctAnswers: [correctIndex.toString()],
type: 'single'
};
}); // ✅ closes map()
// 🔥 Final data
const data = {
title: this.title,
timer: this.timer,
category: this.category,
difficulty: this.difficulty,
questions: formattedQuestions
}; // ✅ closes data object
this.quizService.updateHRQuiz(this.quizId, data).subscribe({
next: () => {
this.saving.set(false);
this.success.set('Quiz updated successfully!');
setTimeout(() => this.router.navigate(['/hr/quizzes']), 1200);
},
error: (err) => {
this.saving.set(false);
this.error.set(err.error?.message || 'Failed to update quiz');
}
}); // ✅ closes subscribe
} // ✅ closes function
updateQuestion(index: number, field: string, value: any): void {
const q = [...this.questions()];
q[index] = { ...q[index], [field]: value };
this.questions.set(q);
}
updateOption(qIndex: number, optIndex: number, value: string): void {
const q = [...this.questions()];
const opts = [...(q[qIndex].options || [])];
opts[optIndex] = value;
q[qIndex] = { ...q[qIndex], options: opts };
this.questions.set(q);
}
removeQuestion(index: number): void {
const q = this.questions().filter((_, i) => i !== index);
this.questions.set(q);
}
addQuestion(): void {
this.questions.set([...this.questions(), {
question: '', options: ['', '', '', ''], correctAnswer: '', marks: 1
}]);
}
}
.dashboard-layout { display: flex; min-height: 100vh; background: #0f1117; }
.sidebar { width: 260px; background: rgba(255,255,255,0.03); border-right: 1px solid rgba(255,255,255,0.06); display: flex; flex-direction: column; padding: 24px 16px; position: fixed; top: 0; left: 0; bottom: 0; z-index: 10; }
.sidebar-header { display: flex; align-items: center; gap: 10px; padding: 0 8px; margin-bottom: 32px; flex-wrap: wrap; }
.sidebar-header .logo-icon { font-size: 28px; }
.sidebar-header h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0; }
.role-badge { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; text-transform: uppercase; }
.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.nav-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; color: rgba(255,255,255,0.6); text-decoration: none; font-size: 14px; font-weight: 500; transition: all 0.2s; }
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: rgba(102,126,234,0.15); color: #667eea; }
.nav-icon { font-size: 18px; }
.sidebar-footer { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px; }
.user-info { display: flex; align-items: center; gap: 12px; padding: 8px; margin-bottom: 12px; }
.user-avatar { width: 38px; height: 38px; border-radius: 10px; background: linear-gradient(135deg, #667eea, #764ba2); display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 16px; }
.user-details { display: flex; flex-direction: column; }
.user-name { color: #fff; font-size: 13px; font-weight: 600; }
.user-email { color: rgba(255,255,255,0.4); font-size: 11px; }
.logout-btn { width: 100%; padding: 10px; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); border-radius: 10px; color: #f87171; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; }
.logout-btn:hover { background: rgba(239,68,68,0.2); }
.main-content { flex: 1; margin-left: 260px; padding: 40px; }
.page-header { margin-bottom: 32px; }
.page-header h1 { font-size: 28px; font-weight: 700; color: #fff; margin: 0 0 8px; }
.page-header p { color: rgba(255,255,255,0.5); font-size: 15px; margin: 0; }
.alert { padding: 14px 20px; border-radius: 12px; margin-bottom: 20px; font-size: 14px; font-weight: 500; animation: fadeIn 0.3s ease; }
.alert-success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #f87171; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.create-section { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 32px; margin-bottom: 40px; }
.quiz-form { display: flex; flex-direction: column; gap: 24px; }
.form-row { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
.form-group label { display: block; color: rgba(255,255,255,0.7); font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.form-group input {
width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px; color: #fff; font-size: 15px; outline: none; transition: all 0.3s; font-family: inherit; box-sizing: border-box;
}
.form-group input::placeholder { color: rgba(255,255,255,0.3); }
.form-group input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.file-upload {
border: 2px dashed rgba(255,255,255,0.15); border-radius: 16px; padding: 32px;
text-align: center; cursor: pointer; transition: all 0.3s; display: flex; flex-direction: column;
align-items: center; gap: 8px;
}
.file-upload:hover { border-color: #667eea; background: rgba(102,126,234,0.05); }
.upload-icon { font-size: 36px; }
.upload-text { color: rgba(255,255,255,0.7); font-size: 15px; font-weight: 500; }
.upload-hint { color: rgba(255,255,255,0.35); font-size: 12px; }
.file-icon { font-size: 28px; }
.file-name { color: #fff; font-size: 15px; font-weight: 600; }
.file-change { color: #667eea; font-size: 13px; font-weight: 500; }
.btn { padding: 14px 24px; border: none; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; box-shadow: 0 4px 15px rgba(102,126,234,0.3); }
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 25px rgba(102,126,234,0.4); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.section-title { color: #fff; font-size: 18px; font-weight: 600; margin: 0 0 20px; }
.loading-state { display: flex; justify-content: center; padding: 40px; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1); border-top-color: #667eea; border-radius: 50%; animation: spin 0.8s linear infinite; }
.empty-state { text-align: center; padding: 60px 0; }
.empty-icon { font-size: 48px; display: block; margin-bottom: 16px; }
.empty-state h3 { color: #fff; font-size: 18px; margin: 0 0 8px; }
.empty-state p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 0; }
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.quiz-card {
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px; padding: 20px; transition: all 0.2s;
}
.quiz-card:hover { border-color: rgba(255,255,255,0.15); }
.quiz-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.quiz-card-header h4 { color: #fff; font-size: 16px; font-weight: 600; margin: 0; }
.delete-btn { background: none; border: none; font-size: 16px; cursor: pointer; padding: 4px; opacity: 0.5; transition: opacity 0.2s; }
.delete-btn:hover { opacity: 1; }
.quiz-meta { display: flex; gap: 16px; color: rgba(255,255,255,0.5); font-size: 13px; margin-bottom: 8px; }
.quiz-date { color: rgba(255,255,255,0.3); font-size: 12px; }
@media (max-width: 768px) {
.sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); }
.main-content { margin-left: 0; padding: 24px; }
.dashboard-layout { flex-direction: column; }
.form-row { grid-template-columns: 1fr; }
}
.form-select {
width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px; color: #fff; font-size: 15px; outline: none; transition: all 0.3s; font-family: inherit; box-sizing: border-box;
}
.form-select:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.assignment-box {
margin-top: 12px;
padding: 16px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
}
.list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
background: rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.mt-2 { margin-top: 8px; }
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: background 0.15s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: rgba(255,255,255,0.05);
}
.list-item input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.item-details {
display: flex;
flex-direction: column;
}
.item-name {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.item-sub {
font-size: 12px;
color: rgba(255,255,255,0.5);
}
.empty-state {
padding: 20px;
text-align: center;
color: rgba(255,255,255,0.5);
font-size: 13px;
font-style: italic;
}
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-header">
<span class="logo-icon">📝</span>
<h2>QuizMaster</h2><span class="role-badge">Admin</span>
</div>
<nav class="sidebar-nav">
<a routerLink="/admin/dashboard" class="nav-item"><span class="nav-icon">🏠</span><span>Dashboard</span></a>
<a routerLink="/admin/users" class="nav-item"><span class="nav-icon">👥</span><span>Users</span></a>
<a routerLink="/admin/generate-quiz" class="nav-item active"><span class="nav-icon"></span><span>Generate
Quiz</span></a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">{{ authService.currentUser()?.name?.charAt(0) || 'A' }}</div>
<div class="user-details"><span class="user-name">{{ authService.currentUser()?.name }}</span><span
class="user-email">{{ authService.currentUser()?.email }}</span></div>
</div>
<button class="logout-btn" (click)="logout()"><span>🚪</span> Logout</button>
</div>
</aside>
<main class="main-content">
<div class="page-header">
<h1>Generate Quiz</h1>
<p>Create a new quiz by uploading questions from an Excel file</p>
</div>
@if (success()) {
<div class="alert alert-success">✅ {{ success() }}</div>
}
@if (error()) {
<div class="alert alert-error">⚠️ {{ error() }}</div>
}
<div class="create-section">
<form (ngSubmit)="onSubmit()" class="quiz-form">
<div class="form-row">
<div class="form-group">
<label for="title">Quiz Name</label>
<input type="text" id="title" [(ngModel)]="title" name="title"
placeholder="e.g., JavaScript Fundamentals" />
</div>
<div class="form-group">
<label for="timer">Timer (minutes)</label>
<input type="number" id="timer" [(ngModel)]="timer" name="timer" min="1" placeholder="30" />
</div>
</div>
<div class="form-group">
<label>Questions File (.xlsx)</label>
<div class="file-upload" (click)="fileInput.click()">
<input #fileInput type="file" accept=".xlsx,.xls" (change)="onFileSelected($event)" hidden />
@if (fileName()) {
<span class="file-icon">📄</span>
<span class="file-name">{{ fileName() }}</span>
<span class="file-change">Change</span>
} @else {
<span class="upload-icon">📤</span>
<span class="upload-text">Click to upload Excel file</span>
<span class="upload-hint">Format: Question | Option1 | Option2 | Option3 | Option4 | Correct</span>
}
</div>
</div>
<div class="form-group">
<label>Assign Quiz To</label>
<select class="form-select" [ngModel]="assignmentType()" (ngModelChange)="assignmentType.set($event)" name="assignmentType">
<option value="all">All Candidates</option>
<option value="users">Individual People</option>
<option value="groups">Specific Groups</option>
</select>
</div>
@if (assignmentType() === 'users') {
<div class="form-group assignment-box">
<label>Search Candidates</label>
<input class="form-input" type="text" [ngModel]="userSearchQuery()" (ngModelChange)="userSearchQuery.set($event); onUserSearch()" name="userSearchQuery" placeholder="Search by name or email...">
<div class="list-container mt-2">
@for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers().includes(user._id)"
(change)="toggleUserSelection(user._id)">
<div class="item-details">
<span class="item-name">{{ user.name }}</span>
<span class="item-sub">{{ user.email }}</span>
</div>
</div>
}
@if (filteredUsers.length === 0) {
<div class="empty-state">No candidates found</div>
}
</div>
</div>
}
@if (assignmentType() === 'groups') {
<div class="form-group assignment-box">
<label>Select Groups</label>
<div class="list-container mt-2">
@for (group of groups(); track group) {
<div class="list-item" (click)="onGroupToggle(group, {target: {checked: !selectedGroups().includes(group)}})">
<input type="checkbox" [checked]="selectedGroups().includes(group)"
(change)="onGroupToggle(group, $event)">
<div class="item-details">
<span class="item-name">{{ group }}</span>
</div>
</div>
}
@if (groups().length === 0) {
<div class="empty-state">No groups found</div>
}
</div>
</div>
}
<button mat-raised-button color="primary" type="submit" class="btn btn-primary" [disabled]="loading()">
@if (loading()) {
<span class="spinner"></span> Creating Quiz...
} @else {
🚀 Create Quiz
}
</button>
</form>
</div>
<!-- Existing Quizzes -->
<h2 class="section-title">Existing Quizzes</h2>
@if (loadingQuizzes()) {
<div class="loading-state">
<div class="loader"></div>
</div>
} @else if (quizzes().length === 0) {
<div class="empty-state">
<span class="empty-icon">📋</span>
<h3>No quizzes yet</h3>
<p>Create your first quiz above</p>
</div>
} @else {
<div class="quiz-grid">
@for (quiz of quizzes(); track quiz._id) {
<div class="quiz-card">
<div class="quiz-card-header">
<h4>{{ quiz.title }}</h4>
<button class="delete-btn" (click)="deleteQuiz(quiz._id)">🗑️</button>
</div>
<div class="quiz-meta">
<span>⏱️ {{ quiz.timer }} min</span>
<span>📝 {{ quiz.totalQuestions }} questions</span>
</div>
<span class="quiz-date">Created {{ quiz.createdAt | date:'mediumDate' }}</span>
</div>
}
</div>
}
</main>
</div>
\ No newline at end of file
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-generate-quiz',
imports: [CommonModule, FormsModule, RouterLink, MatButtonModule],
templateUrl: './generate-quiz.html',
styleUrl: './generate-quiz.css',
})
export class GenerateQuiz {
title = '';
timer = 30;
selectedFile: File | null = null;
fileName = signal<string>('');
groups = signal<any[]>([]); // Actually used for groups now, let's keep it as string[] if the backend returns strings
availableUsers: any[] = [];
filteredUsers: any[] = [];
assignmentType = signal<string>('all'); // 'all', 'users', 'groups'
selectedUsers = signal<string[]>([]);
selectedGroups = signal<string[]>([]);
userSearchQuery = signal<string>('');
loading = signal<boolean>(false);
success = signal<string>('');
error = signal<string>('');
quizzes = signal<any[]>([]);
loadingQuizzes = signal<boolean>(true);
constructor(public authService: AuthService, private quizService: QuizService) { }
ngOnInit(): void {
this.loadQuizzes();
this.fetchUsersAndGroups();
}
fetchUsersAndGroups(): void {
this.quizService.getHRGroups().subscribe({
next: (res) => {
this.groups.set(res.groups || []);
},
error: () => console.log("Failed to load groups")
});
this.quizService.getHRCandidates().subscribe({
next: (res) => {
this.availableUsers = res.users || [];
this.filteredUsers = [...this.availableUsers];
},
error: () => console.log("Failed to load users")
});
}
onUserSearch(): void {
const q = this.userSearchQuery().toLowerCase().trim();
if (!q) {
this.filteredUsers = [...this.availableUsers];
} else {
this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
}
}
toggleUserSelection(userId: string): void {
const selected = this.selectedUsers();
if (selected.includes(userId)) {
this.selectedUsers.set(selected.filter(id => id !== userId));
} else {
this.selectedUsers.set([...selected, userId]);
}
}
loadQuizzes(): void {
this.quizService.getHRQuizzes().subscribe({
next: (res) => {
this.quizzes.set(res.quizzes);
this.loadingQuizzes.set(false);
},
error: () => this.loadingQuizzes.set(false)
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0];
this.fileName.set(this.selectedFile.name);
}
}
onSubmit(): void {
if (!this.title.trim()) {
this.error.set('Please enter a quiz title');
return;
}
if (!this.timer || this.timer < 1) {
this.error.set('Timer must be at least 1 minute');
return;
}
if (!this.selectedFile) {
this.error.set('Please upload an Excel file');
return;
}
this.loading.set(true);
this.error.set('');
this.success.set('');
const formData = new FormData();
formData.append('title', this.title);
formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile);
const aType = this.assignmentType();
if (aType === 'all') {
formData.append('assignToAll', 'true');
} else if (aType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers()));
} else if (aType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups()));
}
this.quizService.createHRQuiz(formData).subscribe({
next: (res) => {
this.loading.set(false);
this.success.set(`Quiz "${res.quiz.title}" created with ${res.quiz.totalQuestions} questions!`);
this.title = '';
this.timer = 30;
this.selectedFile = null;
this.fileName.set('');
this.loadQuizzes();
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.message || 'Failed to create quiz');
}
});
}
deleteQuiz(quizId: string): void {
if (confirm('Are you sure you want to delete this quiz?')) {
this.quizService.deleteHRQuiz(quizId).subscribe({
next: () => this.loadQuizzes(),
error: (err) => this.error.set(err.error?.message || 'Failed to delete quiz')
});
}
}
onGroupToggle(groupName: string, event: any): void {
const selected = this.selectedGroups();
if (event.target.checked) {
this.selectedGroups.set([...selected, groupName]);
} else {
this.selectedGroups.set(selected.filter(g => g !== groupName));
}
}
logout(): void {
this.authService.logout();
}
}
.page-container { max-width: 1400px; padding: 32px 40px; margin: 0 auto; height: calc(100vh - 64px); display: flex; box-sizing: border-box; }
.split-view { gap: 32px; align-items: stretch; }
.main-workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-y: auto; padding-right: 16px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 8px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 0; }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; }
.card { background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border-color); }
.card-padding { padding: 24px; }
.group-form { display: flex; flex-direction: column; }
.row-align { display: flex; gap: 16px; align-items: stretch; flex-wrap: wrap; }
.input-container { flex: 1; position: relative; display: flex; align-items: center; min-width: 220px; }
.input-container .block-icon { position: absolute; left: 16px; color: var(--text-muted); font-size: 20px; }
.form-input { width: 100%; padding: 14px 16px 14px 44px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 12px; color: var(--text-primary); outline: none; transition: all 0.3s; font-size: 15px; box-sizing: border-box; }
.form-input:focus { border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.form-input-small { padding: 10px 14px; font-size: 14px; border-radius: 8px; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 14px 24px; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: all 0.3s; font-size: 15px; font-family: inherit; }
.btn-primary { background: var(--accent-gradient); color: #fff; box-shadow: 0 4px 15px rgba(102,126,234,0.3); }
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102,126,234,0.4); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.action-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; border-radius: 6px; padding: 6px; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s; }
.action-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.text-danger:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
.text-success:hover { color: #22c55e; background: rgba(34, 197, 94, 0.1); }
/* DRAG AND DROP GROUPS */
.groups-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 24px; }
.group-lane { display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px; overflow: hidden; min-height: 80px; transition: all 0.25s ease-in-out; cursor: pointer; }
.group-lane:hover { border-color: rgba(102,126,234,0.4); transform: translateY(-2px); box-shadow: var(--shadow-md); }
.group-lane.cdk-drop-list-receiving { border-color: var(--accent-primary); background: rgba(102,126,234,0.05); box-shadow: 0 0 0 2px rgba(102,126,234,0.2); transform: scale(1.02); z-index: 10; }
.group-lane-header { padding: 16px 20px; background: rgba(102,126,234,0.05); border-bottom: 1px solid transparent; display: flex; justify-content: space-between; align-items: center; transition: border-bottom 0.2s; pointer-events: none; }
.group-lane:has(.group-lane-body) .group-lane-header { border-bottom: 1px solid var(--border-color); }
.group-actions { pointer-events: auto; }
.group-lane-body { cursor: default; }
.group-header-info { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.lane-title { display: flex; align-items: center; gap: 8px; font-size: 16px; margin: 0; color: var(--text-primary); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lane-title .material-symbols-rounded { font-size: 18px; color: var(--accent-primary); }
.member-count { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.group-actions { display: flex; gap: 4px; }
.group-lane-body { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 10px; background: rgba(0,0,0,0.01); min-height: 100px; }
.empty-body { justify-content: center; align-items: center; }
.drop-placeholder { text-align: center; font-size: 13px; color: var(--text-muted); font-style: italic; opacity: 0.6; padding: 30px; border: 1px dashed var(--border-color); border-radius: 12px; width: 100%; box-sizing: border-box; }
.student-pill { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--border-color); border-radius: 10px; cursor: grab; box-shadow: 0 2px 5px rgba(0,0,0,0.02); transition: transform 0.2s, box-shadow 0.2s; }
.student-pill:active { cursor: grabbing; }
.pill-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, rgba(102,126,234,0.1) 0%, rgba(102,126,234,0.2) 100%); color: var(--accent-primary); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; flex-shrink: 0; }
.pill-info { flex: 1; min-width: 0; }
.pill-name { display: block; font-size: 14px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* UNASSIGNED SIDEBAR */
.unassigned-sidebar { width: 340px; display: flex; flex-direction: column; flex-shrink: 0; height: 100%; overflow: hidden; background: var(--surface); }
.sidebar-header { padding: 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; background: rgba(102,126,234,0.05); }
.sidebar-title { display: flex; align-items: center; gap: 10px; }
.sidebar-title h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-primary); }
.sidebar-title .material-symbols-rounded { color: var(--text-muted); font-size: 20px; }
.sidebar-badge { background: var(--accent-primary); color: white; padding: 2px 8px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.unassigned-list { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.empty-unassigned { text-align: center; padding: 40px 20px; color: var(--text-muted); display: flex; flex-direction: column; align-items: center; gap: 12px; opacity: 0.7; }
.empty-unassigned .material-symbols-rounded { font-size: 40px; color: #22c55e; }
.unassigned-card { display: flex; align-items: center; gap: 16px; padding: 14px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; cursor: grab; transition: border-color 0.2s, box-shadow 0.2s; position: relative; }
.unassigned-card:hover { border-color: var(--accent-primary); box-shadow: 0 4px 12px rgba(102,126,234,0.08); transform: translateY(-2px); }
.unassigned-card:active { cursor: grabbing; }
.unassigned-avatar { width: 44px; height: 44px; border-radius: 50%; background: var(--bg-input); color: var(--text-primary); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; flex-shrink: 0; }
.unassigned-details { flex: 1; min-width: 0; }
.unassigned-details h4 { margin: 0 0 4px 0; font-size: 15px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.unassigned-details p { margin: 0; font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.drag-handle { color: var(--text-muted); cursor: grab; opacity: 0.5; font-size: 20px; }
/* CDK DRAG AND DROP PREVIEWS */
.cdk-drag-preview { box-shadow: 0 10px 30px rgba(0,0,0,0.15); border-radius: 12px; opacity: 0.95; z-index: 1000 !important; cursor: grabbing !important; }
.cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.custom-drag-placeholder { opacity: 0.3; background: rgba(102,126,234,0.1); border: 2px dashed var(--accent-primary); border-radius: 12px; min-height: 60px; transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.unassigned-list.cdk-drop-list-dragging .unassigned-card:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.group-lane-body.cdk-drop-list-dragging .student-pill:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); }
.alert { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-radius: 12px; margin-bottom: 24px; font-size: 14px; font-weight: 500; }
.alert-success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #22c55e; }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(102,126,234,0.2); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
.loading-state { display: flex; justify-content: center; padding: 40px; }
@keyframes spin { 100% { transform: rotate(360deg); } }
@media (max-width: 900px) {
.split-view { flex-direction: column; }
.unassigned-sidebar { width: 100%; height: 400px; }
.page-container { height: auto; padding: 20px 16px; }
}
<div class="page-container animate-fade-in split-view" cdkDropListGroup>
<div class="main-workspace">
<div class="page-header">
<h1>Manage Groups</h1>
<p class="page-subtitle">Create organizational groups and effortlessly drag-and-drop candidates into them.</p>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
<!-- Create Group Area -->
<div class="card card-padding form-card" style="margin-bottom: 30px;">
<h2 class="section-title" style="margin-top: 0; margin-bottom: 16px;">Create New Group</h2>
<form (ngSubmit)="createGroup()" class="group-form">
<div class="form-group row-align">
<div class="input-container">
<span class="material-symbols-rounded block-icon">group_add</span>
<input class="form-input" [ngModel]="newGroupName()" (ngModelChange)="newGroupName.set($event)" name="groupName" placeholder="e.g., Summer Interns 2026">
</div>
<button type="submit" class="btn btn-primary" [disabled]="loading() || !newGroupName().trim()">
@if (loading()) { <span class="spinner"></span> } @else { <span class="material-symbols-rounded">add</span> Add Group }
</button>
</div>
</form>
</div>
<h2 class="section-title">Your Groups</h2>
@if (loadingGroups()) {
<div class="loading-state"><div class="loader"></div></div>
} @else if (groups().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">group_off</span>
<h3>No groups available</h3>
<p>Create your first grouping tier up above</p>
</div>
} @else {
<!-- Outer wrapper handles the drag contexts collectively -->
<div class="groups-grid">
@for (group of groups(); track group) {
<div class="group-lane card" cdkDropList [cdkDropListData]="group" (cdkDropListDropped)="onDrop($event)" (click)="toggleGroupExpansion(group)">
<div class="group-lane-header">
<div class="group-header-info">
@if (editingGroup() === group) {
<input type="text" class="form-input form-input-small" [ngModel]="editName()" (ngModelChange)="editName.set($event)" autofocus>
} @else {
<h4 class="lane-title"><span class="material-symbols-rounded">workspaces</span> {{ group }}</h4>
<span class="member-count">{{ getStudentsInGroup(group).length }} Members</span>
}
</div>
<div class="group-actions" (click)="$event.stopPropagation()">
@if (editingGroup() === group) {
<button class="action-btn text-success" (click)="saveEdit(group)" title="Save"><span class="material-symbols-rounded">check</span></button>
<button class="action-btn text-danger" (click)="cancelEdit()" title="Cancel"><span class="material-symbols-rounded">close</span></button>
} @else {
<button class="action-btn" (click)="startEdit(group)" title="Edit"><span class="material-symbols-rounded">edit</span></button>
<button class="action-btn text-danger" (click)="deleteGroup(group)" title="Delete"><span class="material-symbols-rounded">delete</span></button>
}
</div>
</div>
<!-- Drop Zone Body (Displayed on Expansion) -->
@if (expandedGroup() === group) {
<div class="group-lane-body" [class.empty-body]="getStudentsInGroup(group).length === 0" (click)="$event.stopPropagation()">
@if (getStudentsInGroup(group).length === 0) {
<div class="drop-placeholder">No candidates found in {{group}}</div>
}
@for (student of getStudentsInGroup(group); track student._id) {
<div class="student-pill card" cdkDrag [cdkDragData]="student">
<div class="pill-avatar">{{ student.name.charAt(0).toUpperCase() }}</div>
<div class="pill-info">
<span class="pill-name">{{ student.name }}</span>
</div>
<button class="action-btn text-danger" title="Remove" (click)="removeFromGroup(student)">
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
</button>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
}
</div>
}
</div>
}
</div>
<!-- Sidebar: Unassigned Candidates -->
<aside class="unassigned-sidebar card">
<div class="sidebar-header">
<div class="sidebar-title">
<span class="material-symbols-rounded">person_search</span>
<h2>Unassigned Candidates</h2>
</div>
<div class="sidebar-badge">{{ unassignedStudents().length }}</div>
</div>
<div class="unassigned-list" cdkDropList [cdkDropListData]="'General'" (cdkDropListDropped)="onDrop($event)">
@if (unassignedStudents().length === 0) {
<div class="empty-unassigned">
<span class="material-symbols-rounded">done_all</span>
<p>All candidates are matched to groups!</p>
</div>
}
@for (student of unassignedStudents(); track student._id) {
<div class="unassigned-card" cdkDrag [cdkDragData]="student">
<div class="unassigned-avatar">{{ student.name.charAt(0).toUpperCase() }}</div>
<div class="unassigned-details">
<h4>{{ student.name }}</h4>
<p>{{ student.email }}</p>
</div>
<span class="material-symbols-rounded drag-handle">drag_indicator</span>
<div class="custom-drag-placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
</aside>
</div>
import { Component, signal, OnInit, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-manage-groups',
imports: [CommonModule, FormsModule, RouterLink, DragDropModule],
templateUrl: './manage-groups.html',
styleUrl: './manage-groups.css',
})
export class HRManageGroupsComponent {
groups = signal<string[]>([]);
allStudents = signal<any[]>([]);
newGroupName = signal<string>('');
loading = signal<boolean>(false);
error = signal<string>('');
success = signal<string>('');
loadingGroups = signal<boolean>(true);
editingGroup = signal<string | null>(null);
editName = signal<string>('');
expandedGroup = signal<string | null>(null);
unassignedStudents = computed(() => {
return this.allStudents().filter(s => !s.group || s.group === 'General');
});
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.loadGroups();
this.loadStudents();
}
loadGroups(): void {
this.loadingGroups.set(true);
this.quizService.getGroups().subscribe({
next: (res) => {
this.groups.set(res.groups || []);
this.loadingGroups.set(false);
},
error: (err) => {
this.error.set('Failed to load groups');
this.loadingGroups.set(false);
}
});
}
loadStudents(): void {
this.quizService.getUsers('candidate').subscribe({
next: (res) => {
// Since getUsers('candidate') might not exist perfectly on HR route if we don't change HR route, wait, HR route has getHRCandidates
// Let's rely on standard getUsers which points to getBaseUrl()/users depending on role if properly set. Actually quiz.service.ts uses adminUrl directly for getUsers. Let's see. I will dynamically fetch.
this.allStudents.set(res.users ? res.users.filter((u:any) => u.role === 'candidate') : (res.candidates || []));
},
error: () => {}
});
}
getStudentsInGroup(groupName: string): any[] {
return this.allStudents().filter(s => s.group === groupName);
}
toggleGroupExpansion(groupName: string, event?: Event): void {
if (event) {
event.stopPropagation();
}
if (this.expandedGroup() === groupName) {
this.expandedGroup.set(null);
} else {
this.expandedGroup.set(groupName);
}
}
createGroup(): void {
if (!this.newGroupName().trim()) {
this.error.set('Group name cannot be blank');
return;
}
this.loading.set(true);
this.error.set('');
this.success.set('');
this.quizService.createGroup(this.newGroupName()).subscribe({
next: () => {
this.success.set('Group created successfully!');
this.loading.set(false);
this.newGroupName.set('');
this.loadGroups();
},
error: (err: any) => {
this.error.set(err.error?.message || 'Failed to create group');
this.loading.set(false);
}
});
}
startEdit(name: string): void {
this.editingGroup.set(name);
this.editName.set(name);
}
cancelEdit(): void {
this.editingGroup.set(null);
}
saveEdit(oldName: string): void {
const trimmed = this.editName().trim();
if (!trimmed || trimmed === oldName) {
this.cancelEdit();
return;
}
this.error.set('');
this.success.set('');
this.quizService.updateGroup(oldName, trimmed).subscribe({
next: () => {
this.success.set('Group updated successfully!');
this.cancelEdit();
this.loadGroups();
// Update local students cache
this.allStudents.update(students =>
students.map(s => s.group === oldName ? { ...s, group: trimmed } : s)
);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to update group');
}
});
}
deleteGroup(name: string): void {
if (confirm(`Are you sure you want to delete the group "${name}"?\nUsers mapped to this group will moved to Unassigned.`)) {
this.error.set('');
this.success.set('');
this.quizService.deleteGroup(name).subscribe({
next: () => {
this.success.set('Group deleted successfully!');
this.loadGroups();
// Move students to General
this.allStudents.update(students =>
students.map(s => s.group === name ? { ...s, group: 'General' } : s)
);
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to delete group');
}
});
}
}
onDrop(event: CdkDragDrop<string>) {
if (event.previousContainer === event.container) {
return; // No change
}
const student = event.item.data;
const previousGroup = event.previousContainer.data;
const newGroup = event.container.data;
this.allStudents.update(students =>
students.map(s => s._id === student._id ? { ...s, group: newGroup } : s)
);
this.quizService.assignUserGroup(student._id, newGroup).subscribe({
next: () => {
// Success
this.success.set(``);
},
error: () => {
this.error.set('Failed to assign user to group');
this.loadStudents();
}
});
}
removeFromGroup(student: any) {
this.allStudents.update(students =>
students.map(s => s._id === student._id ? { ...s, group: 'General' } : s)
);
this.quizService.assignUserGroup(student._id, 'General').subscribe({
next: () => {},
error: () => this.loadStudents()
});
}
}
......@@ -4,4 +4,46 @@
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 4px 0 0; }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
.quiz-card { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.quiz-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.quiz-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; line-height: 1.4; }
.quiz-meta { display: flex; flex-wrap: wrap; gap: 16px; }
.meta-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-item .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.quiz-topic { font-size: 13px; color: var(--text-muted); background: var(--bg-tertiary); padding: 6px 12px; border-radius: var(--radius-sm); }
.quiz-card-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border-subtle); }
.btn-primary:hover {
color: #fff; /* force text to stay white */
}
.btn-primary .material-symbols-rounded {
color: inherit;
}
.btn-primary:hover .material-symbols-rounded {
color: #fff;
}
.attempted-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.attempted-badge .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
@media (max-width: 768px) {
.page-container { padding: 20px 16px; }
.quiz-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<div>
<h1>My Quizzes</h1>
<p class="page-subtitle">Manage quizzes you've created</p>
<h1>Quizzes</h1>
<p class="page-subtitle">Manage all quizzes in the system</p>
</div>
<a routerLink="/hr/create-quiz" class="btn btn-primary">
<a routerLink="/admin/create-quiz" class="btn btn-primary">
<span class="material-symbols-rounded">add</span> Create Quiz
</a>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
<div class="alert alert-error">
<span class="material-symbols-rounded">error</span> {{ error() }}
</div>
}
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading quizzes...</p></div>
<div class="loading-center">
<div class="spinner spinner-lg"></div>
<p>Loading quizzes...</p>
</div>
} @else if (quizzes().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded">quiz</span>
<h3>No quizzes yet</h3>
<p>Create your first quiz to get started.</p>
<a routerLink="/admin/create-quiz" class="btn btn-primary" style="margin-top: 16px;">Create Quiz</a>
</div>
} @else {
<div class="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Questions</th>
<th>Timer</th>
<th>Category</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (quiz of quizzes(); track quiz._id) {
<tr>
<td><strong>{{ quiz.title }}</strong></td>
<td>{{ quiz.totalQuestions }}</td>
<td>{{ quiz.timer }} min</td>
<td>{{ quiz.category || '—' }}</td>
<td>{{ quiz.createdAt | date:'mediumDate' }}</td>
<td>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)">
<span class="material-symbols-rounded">delete</span>
</button>
</td>
</tr>
}
</tbody>
</table>
<div class="quiz-grid stagger-children">
@for (quiz of quizzes(); track quiz._id) {
<div class="card card-hover quiz-card">
<div class="quiz-card-header">
<h3 class="quiz-title">{{ quiz.title }}</h3>
<span class="badge" [ngClass]="getDifficultyClass(quiz.difficulty)">
{{ quiz.difficulty || 'General' }}
</span>
</div>
<div class="quiz-meta">
<div class="meta-item">
<span class="material-symbols-rounded">help</span>
{{ quiz.totalQuestions }} questions
</div>
<div class="meta-item">
<span class="material-symbols-rounded">timer</span>
{{ quiz.timer }} min
</div>
<div class="meta-item">
<span class="material-symbols-rounded">category</span>
{{ quiz.category || 'Uncategorized' }}
</div>
</div>
<div class="quiz-card-actions">
<a [routerLink]="['/admin/quiz', quiz._id, 'assign']" class="btn btn-primary btn-sm">
<span class="material-symbols-rounded">person_add</span> Assign
</a>
@if (quiz.attemptCount > 0) {
<span class="attempted-badge">
<span class="material-symbols-rounded">lock</span>
{{ quiz.attemptCount }} attempt{{ quiz.attemptCount > 1 ? 's' : '' }}
</span>
} @else {
<a [routerLink]="['/admin/quiz', quiz._id, 'edit']" class="btn btn-outline btn-sm">
<span class="material-symbols-rounded">edit</span> Edit
</a>
<button class="btn btn-danger btn-sm" (click)="deleteQuiz(quiz._id)">
<span class="material-symbols-rounded">delete</span> Delete
</button>
}
</div>
</div>
}
</div>
}
</div>
......@@ -11,7 +11,7 @@ import { QuizService } from '../../../services/quiz.service';
styleUrl: './quizzes.css'
})
export class HRQuizzesComponent implements OnInit {
quizzes = signal<any[]>([]);
quizzes = signal<any[]>([]);
loading = signal(true);
error = signal('');
......@@ -22,6 +22,7 @@ export class HRQuizzesComponent implements OnInit {
}
loadQuizzes(): void {
this.loading.set(true);
this.quizService.getHRQuizzes().subscribe({
next: (res) => { this.quizzes.set(res.quizzes); this.loading.set(false); },
error: () => this.loading.set(false)
......@@ -35,4 +36,13 @@ export class HRQuizzesComponent implements OnInit {
error: (err) => this.error.set(err.error?.message || 'Cannot delete quiz')
});
}
getDifficultyClass(d: string): string {
switch (d?.toLowerCase()) {
case 'easy': return 'badge-success';
case 'medium': return 'badge-warning';
case 'hard': return 'badge-danger';
default: return 'badge-primary';
}
}
}
.page-container { padding: 32px 40px; max-width: 900px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); font-weight: 500; margin-bottom: 20px; }
.back-link:hover { color: var(--accent-primary); }
.loading-center { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loading-center p { color: var(--text-muted); }
.submission-header { margin-bottom: 28px; }
.header-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; }
.header-row h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin: 0; }
.sub-info { font-size: 14px; color: var(--text-muted); margin: 4px 0 0; }
.score-display { text-align: right; }
.page-container {
padding: 32px 40px;
max-width: 1400px;
}
.loading-state { display: flex; flex-direction: column; align-items: center; padding: 80px 0; gap: 16px; }
.loader { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: #667eea; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-state p { color: var(--text-secondary); }
.breadcrumb { display: flex; align-items: center; gap: 8px; margin-bottom: 28px; flex-wrap: wrap; }
.breadcrumb a { color: #667eea; text-decoration: none; font-size: 14px; font-weight: 500; }
.breadcrumb a:hover { color: #8b9cf7; }
.breadcrumb span { color: var(--text-muted); font-size: 13px; }
.breadcrumb .current { color: var(--text-primary); font-weight: 600; }
.score-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 36px; }
.score-card {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px;
padding: 20px; display: flex; flex-direction: column; gap: 8px;
}
.score-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.score-value { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.score-percent { display: block; font-size: 16px; font-weight: 600; margin-top: 2px; }
.score-percent.good { color: var(--success); }
.score-percent.avg { color: var(--warning); }
.score-percent.poor { color: var(--danger); }
.meta-row { display: flex; gap: 24px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
.meta-row span { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.meta-row .material-symbols-rounded { font-size: 18px; color: var(--text-muted); }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
.answer-card { margin-bottom: 12px; border-left: 4px solid transparent; }
.answer-card.correct { border-left-color: var(--success); }
.answer-card.wrong { border-left-color: var(--danger); }
.answer-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.q-num { font-size: 12px; font-weight: 700; color: var(--accent-primary); background: var(--accent-primary-light); padding: 3px 10px; border-radius: var(--radius-full); }
.result-icon { font-size: 22px; }
.answer-card.correct .result-icon { color: var(--success); }
.answer-card.wrong .result-icon { color: var(--danger); }
.question-text { font-size: 15px; color: var(--text-primary); font-weight: 500; margin-bottom: 12px; line-height: 1.5; }
.answer-details { display: flex; flex-direction: column; gap: 6px; }
.answer-row { display: flex; gap: 8px; font-size: 13px; }
.answer-label { color: var(--text-muted); min-width: 130px; }
.answer-value { color: var(--text-primary); font-weight: 500; }
.wrong-text { color: var(--danger); }
.correct-text { color: var(--success); }
@media (max-width: 768px) { .page-container { padding: 20px 16px; } }
.score-value.small { font-size: 14px; }
.score-value.good { color: var(--success); }
.score-value.avg { color: var(--warning); }
.score-value.poor { color: var(--danger); }
.section-title { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); }
.questions-list { display: flex; flex-direction: column; gap: 20px; }
.question-card {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px;
padding: 24px; transition: all 0.2s;
}
.question-card.correct { border-left: 4px solid var(--success); }
.question-card.wrong { border-left: 4px solid var(--danger); }
.q-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.q-number { background: rgba(102,126,234,0.15); color: var(--accent-primary); padding: 4px 12px; border-radius: 8px; font-weight: 700; font-size: 13px; }
.q-type-badge { background: var(--bg-input); color: var(--text-secondary); padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.q-status { margin-left: auto; font-weight: 600; font-size: 13px; }
.q-status.correct { color: var(--success); }
.q-status:not(.correct) { color: var(--danger); }
.q-text { font-size: 15px; line-height: 1.6; margin: 0 0 16px; color: var(--text-primary); }
.options-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.option {
padding: 12px 16px; border-radius: 10px; font-size: 14px; color: var(--text-primary);
background: var(--bg-input); border: 1px solid var(--border-color);
display: flex; align-items: center; gap: 8px;
}
.option.correct-answer { background: rgba(74,222,128,0.1); border-color: rgba(74,222,128,0.3); color: var(--success); }
.option.wrong-answer { background: rgba(248,113,113,0.1); border-color: rgba(248,113,113,0.3); color: var(--danger); }
.option.student-answer:not(.wrong-answer) { background: rgba(74,222,128,0.1); border-color: rgba(74,222,128,0.3); color: var(--success); }
.option-marker { font-weight: 700; font-size: 16px; }
@media (max-width: 768px) {
.page-container { padding: 24px 16px; }
.options-grid { grid-template-columns: 1fr; }
.score-summary { grid-template-columns: 1fr 1fr; }
}
<div class="page-container animate-fade-in">
<a routerLink="/hr/candidates" class="back-link">
<span class="material-symbols-rounded">arrow_back</span> Back to Candidates
</a>
@if (loading()) {
<div class="loading-state"><div class="loader"></div><p>Loading details...</p></div>
} @else if (submission()) {
<div class="breadcrumb">
<a routerLink="/admin/users">Users</a> <span></span>
<a [routerLink]="['/admin/users', submission().student?.id || submission().student?._id, 'history']">{{ submission().student?.name }}</a>
<span></span> <span class="current">{{ submission().quiz?.title }}</span>
</div>
@if (loading()) {
<div class="loading-center"><div class="spinner spinner-lg"></div><p>Loading submission...</p></div>
} @else if (submission()) {
<div class="submission-header card card-padding">
<div class="header-row">
<div>
<h1>{{ submission().quizId?.title || 'Quiz' }}</h1>
<p class="sub-info">{{ submission().studentId?.name }} • {{ submission().studentId?.email }}</p>
</div>
<div class="score-display">
<!-- Score Summary -->
<div class="score-summary">
<div class="score-card">
<span class="score-label">Score</span>
<span class="score-value">{{ submission().score }}/{{ submission().totalMarks }}</span>
<span class="score-percent" [class.good]="submission().percentage >= 70"
[class.avg]="submission().percentage >= 40 && submission().percentage < 70"
[class.poor]="submission().percentage < 40">{{ submission().percentage }}%</span>
</div>
<div class="score-card">
<span class="score-label">Percentage</span>
<span class="score-value" [class.good]="submission().percentage >= 70" [class.avg]="submission().percentage >= 40 && submission().percentage < 70" [class.poor]="submission().percentage < 40">{{ submission().percentage }}%</span>
</div>
<div class="score-card">
<span class="score-label">Time Taken</span>
<span class="score-value">{{ formatTime(submission().timeTaken) }}</span>
</div>
<div class="score-card">
<span class="score-label">Submitted</span>
<span class="score-value small">{{ submission().submittedAt | date:'medium' }}</span>
</div>
</div>
<div class="meta-row">
<span><span class="material-symbols-rounded">timer</span> {{ formatTime(submission().timeTaken) }}</span>
<span><span class="material-symbols-rounded">calendar_today</span> {{ submission().submittedAt | date:'medium' }}</span>
</div>
</div>
<h2 class="section-title">Answers</h2>
@for (ans of detailedAnswers(); track $index; let i = $index) {
<div class="card card-padding answer-card" [class.correct]="ans.isCorrect" [class.wrong]="!ans.isCorrect">
<div class="answer-header">
<span class="q-num">Q{{ i + 1 }}</span>
<span class="material-symbols-rounded result-icon">{{ ans.isCorrect ? 'check_circle' : 'cancel' }}</span>
</div>
<p class="question-text">{{ ans.question }}</p>
<div class="answer-details">
<div class="answer-row">
<span class="answer-label">Student's Answer:</span>
<span class="answer-value" [class.wrong-text]="!ans.isCorrect">{{ ans.studentAnswer || '—' }}</span>
<!-- Questions -->
<h2 class="section-title">Detailed Answers</h2>
<div class="questions-list">
@for (q of detailedAnswers(); track q.questionId; let i = $index) {
<div class="question-card" [class.correct]="q.isCorrect" [class.wrong]="!q.isCorrect">
<div class="q-header">
<span class="q-number">Q{{ i + 1 }}</span>
<span class="q-type-badge">{{ q.type === 'mcq' ? 'MCQ' : 'Single' }}</span>
<span class="q-status" [class.correct]="q.isCorrect">{{ q.isCorrect ? '✓ Correct' : '✗ Wrong' }}</span>
</div>
<p class="q-text">{{ q.question }}</p>
<div class="options-grid">
@for (opt of q.options; track opt) {
<div class="option"
[class.correct-answer]="q.correctAnswers.includes(opt)"
[class.student-answer]="q.studentAnswers.includes(opt)"
[class.wrong-answer]="q.studentAnswers.includes(opt) && !q.correctAnswers.includes(opt)">
<span class="option-marker">
@if (q.correctAnswers.includes(opt)) { ✓ }
@else if (q.studentAnswers.includes(opt)) { ✗ }
</span>
{{ opt }}
</div>
}
</div>
</div>
<div class="answer-row">
<span class="answer-label">Correct Answer:</span>
<span class="answer-value correct-text">{{ ans.correctAnswer }}</span>
</div>
</div>
}
</div>
}
}
</div>
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { QuizService } from '../../../services/quiz.service';
@Component({
......@@ -12,26 +13,38 @@ import { QuizService } from '../../../services/quiz.service';
})
export class HRSubmissionDetailComponent implements OnInit {
submission = signal<any>(null);
detailedAnswers = signal<any[]>([]);
loading = signal(true);
constructor(private route: ActivatedRoute, private quizService: QuizService) {}
ngOnInit(): void {
const id = this.route.snapshot.params['submissionId'];
this.quizService.getHRSubmissionDetails(id).subscribe({
next: (res) => {
this.submission.set(res.submission);
this.detailedAnswers.set(res.detailedAnswers);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
detailedAnswers = signal<any[]>([]);
loading = signal<boolean>(true);
constructor(
private route: ActivatedRoute,
public authService: AuthService,
private quizService: QuizService
) {}
ngOnInit(): void {
const submissionId = this.route.snapshot.params['submissionId'];
this.loadDetails(submissionId);
}
loadDetails(id: string): void {
this.quizService.getSubmissionDetails(id).subscribe({
next: (res) => {
this.submission.set(res.submission);
this.detailedAnswers.set(res.detailedAnswers);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
logout(): void {
this.authService.logout();
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment