diff --git a/otpcasestudy-angular-app/src/app/app.component.spec.ts b/otpcasestudy-angular-app/src/app/app.component.spec.ts new file mode 100644 index 0000000..33960a6 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/app.component.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'otp-admin' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('otp-admin'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, otp-admin'); + }); +}); diff --git a/otpcasestudy-angular-app/src/app/app.config.ts b/otpcasestudy-angular-app/src/app/app.config.ts index 71f59eb..844bd08 100644 --- a/otpcasestudy-angular-app/src/app/app.config.ts +++ b/otpcasestudy-angular-app/src/app/app.config.ts @@ -3,8 +3,16 @@ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { AuthInterceptor } from './services/auth-interceptor.service'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideAnimationsAsync(), provideHttpClient()] + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideAnimationsAsync(), + provideHttpClient(withInterceptorsFromDi()), + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true } + ] }; diff --git a/otpcasestudy-angular-app/src/app/app.routes.ts b/otpcasestudy-angular-app/src/app/app.routes.ts index c7ca86a..6662a08 100644 --- a/otpcasestudy-angular-app/src/app/app.routes.ts +++ b/otpcasestudy-angular-app/src/app/app.routes.ts @@ -5,11 +5,20 @@ import { ScheduleExamComponent } from './components/admin/schedule-exam/schedule import { UploadQuestionsComponent } from './components/admin/upload-questions/upload-questions.component'; import { ViewCandidatesComponent } from './components/admin/view-candidates/view-candidates.component'; import { DownloadResultsComponent } from './components/admin/download-results/download-results.component'; -import { DownloadAbsentCandidatesComponent } from './components/admin/download-absent-candidates/download-absent-candidates.component'; +import { AdminAuthGuard } from './guards/admin-auth-guard'; +import { LoginGuard } from './guards/login.guard'; +import { LandingComponent } from './components/candidate/landing/landing.component'; +import { AdminLoginComponent } from './components/candidate/admin-login/admin-login.component'; +import { EmailVerificationComponent } from './components/candidate/email-verification/email-verification.component'; +import { RegistrationComponent } from './components/candidate/registration/registration.component'; +import { ExamWindowComponent } from './components/exam-window/exam-window.component'; +import { ExamExitGuard } from './guards/exam-exit.guard'; +import { CandidateLoginComponent } from './components/candidate/candidate-login/candidate-login.component'; export const routes: Routes = [ { path: 'admin', + canActivate: [AdminAuthGuard], component: AdminLayoutComponent, children: [ { path: 'dashboard', component: DashboardComponent }, @@ -17,10 +26,20 @@ export const routes: Routes = [ { path: 'upload-questions', component: UploadQuestionsComponent }, { path: 'view-candidates', component: ViewCandidatesComponent }, { path: 'download-results', component: DownloadResultsComponent }, - { path: 'absent-candidates', component: DownloadAbsentCandidatesComponent }, { path: '', pathMatch: 'full', redirectTo: 'dashboard' } ] }, + { + path: 'login', + canActivate: [LoginGuard], + loadComponent: () => import('./components/admin/login/login.component').then(m => m.LoginComponent) + }, + { path: 'landing', component: LandingComponent }, + { path: 'admin-login', component: AdminLoginComponent }, + { path: 'verify-email', component: EmailVerificationComponent }, + { path: 'registration', component: RegistrationComponent }, + { path: 'exam', component: ExamWindowComponent, canDeactivate: [ExamExitGuard] }, + { path: 'candidate-login', component: CandidateLoginComponent }, { path: '', pathMatch: 'full', redirectTo: 'admin/dashboard' }, - { path: '**', redirectTo: 'admin/dashboard' } -]; \ No newline at end of file + { path: '**', redirectTo: 'landing' } +]; \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.scss b/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.scss index 5572f39..9e5de7b 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.scss +++ b/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.scss @@ -5,7 +5,6 @@ .admin-sidenav { width: 280px; - // background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #3b1877; color: white; border: none; @@ -47,12 +46,12 @@ &:hover { background-color: rgba(255, 255, 255, 0.1); - color: white; + color: #fff; } &.active-link { - background-color: rgba(255, 255, 255, 0.2); - color: white; + background-color: rgba(255, 255, 255, 0.7); + color: #3b1877; font-weight: 500; } @@ -97,7 +96,7 @@ .toolbar-title { font-size: 1.25rem; font-weight: 500; - display: none; // Hidden on small screens + display: none; } } @@ -150,11 +149,7 @@ } @media (min-width: 768px) { - .admin-toolbar { - .toolbar-brand { - .toolbar-title { - display: inline-block; - } - } + .admin-toolbar .toolbar-brand .toolbar-title { + display: inline-block; } } diff --git a/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.ts b/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.ts index bd6fad9..5d9664f 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.ts @@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatBadgeModule } from '@angular/material/badge'; import { MatMenuModule } from '@angular/material/menu'; import { MatDividerModule } from '@angular/material/divider'; +import { AuthAdminService } from '../../../services/auth-admin.service'; @Component({ selector: 'app-admin-layout', @@ -64,14 +65,14 @@ export class AdminLayoutComponent { } ]; - constructor(private router: Router) { } + constructor(private router: Router, private authAdminService: AuthAdminService) { } toggleSidenav() { this.sidenavOpened = !this.sidenavOpened; } logout() { - localStorage.removeItem('otp_admin_logged_in'); - this.router.navigate(['/login']); + this.authAdminService.logout(); + this.router.navigate(['/admin-login']); } } \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/admin/dashboard/dashboard.component.ts b/otpcasestudy-angular-app/src/app/components/admin/dashboard/dashboard.component.ts index 4ea945f..de3f7e5 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/dashboard/dashboard.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/dashboard/dashboard.component.ts @@ -94,7 +94,7 @@ export class DashboardComponent implements OnInit, AfterViewInit { }, error: (err) => { console.error(err); - this.snackBar.open('Failed to load exams', '', { duration: 5000 }); + this.snackBar.open(err?.error?.message || 'Failed to load exams', '', { duration: 5000 }); this.loading = false; } }); @@ -102,13 +102,19 @@ export class DashboardComponent implements OnInit, AfterViewInit { // Fetch active exam count this.examService.getActiveExamCount().subscribe({ next: (count) => this.activeExams = count, - error: (err) => console.error(err) + error: (err) => { + console.error(err) + this.snackBar.open(err?.error?.message || 'Failed to load Active Exam Count', '', { duration: 5000 }); + } }); // Fetch total active candidates this.examService.getTotalActiveCandidates().subscribe({ next: (count) => this.totalCandidates = count, - error: (err) => console.error(err) + error: (err) => { + console.error(err); + this.snackBar.open(err?.error?.message || 'Failed to load Active Candidates', '', { duration: 5000 }); + } }) } @@ -129,12 +135,12 @@ export class DashboardComponent implements OnInit, AfterViewInit { } getStatusColor(status: string): string { - const statusColorMap: { [key: string]: string } = { - upcoming: 'primary', - ongoing: 'accent', - finished: 'warn', - }; - return statusColorMap[status] || 'default'; + switch (status) { + case 'upcoming': return 'primary'; + case 'ongoing': return 'accent'; + case 'ended': return 'warn'; + default: return 'default'; + } } getStatusText(status: string): string { @@ -156,10 +162,6 @@ export class DashboardComponent implements OnInit, AfterViewInit { return 'ended'; } - // viewResults(exam: Exam): void { - // this.snackBar.open(`Viewing results for ${exam.title}`, 'Close', { duration: 2000 }); - // } - downloadResults(exam: Exam): void { this.examService.downloadResults(exam.id).subscribe({ next: (blob) => { @@ -174,7 +176,7 @@ export class DashboardComponent implements OnInit, AfterViewInit { }, error: (err) => { console.error(err); - this.snackBar.open('Failed to download results.', '', { duration: 5000 }); + this.snackBar.open(err?.error?.message || 'Failed to download results.', '', { duration: 5000 }); } }); } diff --git a/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.html b/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.html deleted file mode 100644 index 879c3cc..0000000 --- a/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.html +++ /dev/null @@ -1 +0,0 @@ -

download-absent-candidates works!

diff --git a/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.scss b/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.scss deleted file mode 100644 index e69de29..0000000 diff --git a/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.ts b/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.ts deleted file mode 100644 index cc6fbec..0000000 --- a/otpcasestudy-angular-app/src/app/components/admin/download-absent-candidates/download-absent-candidates.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-download-absent-candidates', - standalone: true, - imports: [], - templateUrl: './download-absent-candidates.component.html', - styleUrl: './download-absent-candidates.component.scss' -}) -export class DownloadAbsentCandidatesComponent { - -} diff --git a/otpcasestudy-angular-app/src/app/components/admin/download-results/download-results.component.ts b/otpcasestudy-angular-app/src/app/components/admin/download-results/download-results.component.ts index 3d9a5c9..20bcff7 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/download-results/download-results.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/download-results/download-results.component.ts @@ -42,8 +42,8 @@ export class DownloadResultsComponent implements OnInit { next: (exams) => { this.exams = exams; }, - error: () => { - this.snackBar.open('Failed to load completed exams.', '', { duration: 5000 }); + error: (err) => { + this.snackBar.open(err?.error?.message || 'Failed to load completed exams.', '', { duration: 5000 }); } }); } @@ -57,8 +57,8 @@ export class DownloadResultsComponent implements OnInit { this.snackBar.open('Results downloaded!', '', { duration: 5000 }); this.downloadingExamId = null; }, - error: () => { - this.snackBar.open('Failed to download results.', '', { duration: 5000 }); + error: (err) => { + this.snackBar.open(err?.error?.message || 'Failed to download results.', '', { duration: 5000 }); this.downloadingExamId = null; } }); @@ -70,7 +70,6 @@ export class DownloadResultsComponent implements OnInit { a.href = url; a.download = filename; a.click(); - a.remove(); window.URL.revokeObjectURL(url); } } diff --git a/otpcasestudy-angular-app/src/app/components/admin/login/login.component.html b/otpcasestudy-angular-app/src/app/components/admin/login/login.component.html index 147cfc4..3a26dca 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/login/login.component.html +++ b/otpcasestudy-angular-app/src/app/components/admin/login/login.component.html @@ -1 +1,53 @@ -

login works!

+
+
+
+ +

NextStep Admin Portal

+

Online Test Platform Management

+
+
+
+ +
+
diff --git a/otpcasestudy-angular-app/src/app/components/admin/login/login.component.scss b/otpcasestudy-angular-app/src/app/components/admin/login/login.component.scss index e69de29..d37985b 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/login/login.component.scss +++ b/otpcasestudy-angular-app/src/app/components/admin/login/login.component.scss @@ -0,0 +1,66 @@ +.login-container { + display: flex; + height: 100vh; + background-color: #f5f5f5; +} +.branding-panel { + width: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + justify-content: center; + align-items: center; + text-align: center; +} +.branding-content .brand-logo { + font-size: 80px; + width: 80px; + height: 80px; + margin-bottom: 24px; +} +.branding-content .brand-title { + font-size: 2.5rem; + font-weight: 600; + margin: 0; +} +.branding-content .brand-subtitle { + font-size: 1.2rem; + opacity: 0.8; +} +.login-panel { + width: 50%; + display: flex; + justify-content: center; + align-items: center; +} +.login-card { + width: 400px; + padding: 24px; +} +.login-card h2 { + text-align: center; + font-size: 1.8rem; + font-weight: 500; + margin-bottom: 24px; +} +.form-field { + width: 100%; + margin-bottom: 20px; +} +.login-button { + width: 100%; + height: 48px; + font-size: 1rem; +} +@media (max-width: 768px) { + .branding-panel { + display: none; + } + .login-panel { + width: 100%; + } + .login-card { + width: 90%; + max-width: 400px; + } +} diff --git a/otpcasestudy-angular-app/src/app/components/admin/login/login.component.ts b/otpcasestudy-angular-app/src/app/components/admin/login/login.component.ts index 2967c55..76cb72e 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/login/login.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/login/login.component.ts @@ -1,12 +1,59 @@ import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; @Component({ selector: 'app-login', standalone: true, - imports: [], + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ], templateUrl: './login.component.html', - styleUrl: './login.component.scss' + styleUrls: ['./login.component.scss'] }) export class LoginComponent { + loginForm: FormGroup; + loading = false; -} + constructor( + private fb: FormBuilder, + private router: Router, + private snackBar: MatSnackBar + ) { + this.loginForm = this.fb.group({ + username: ['', Validators.required], + password: ['', Validators.required] + }); + } + + onSubmit() { + if (this.loginForm.valid) { + this.loading = true; + const { username, password } = this.loginForm.value; + setTimeout(() => { + if (username === 'admin' && password === 'admin123') { + localStorage.setItem('otp_admin_logged_in', 'true'); + this.router.navigate(['/admin/dashboard']); + } else { + this.snackBar.open('Invalid credentials', 'Close', { duration: 2500 }); + } + this.loading = false; + }, 1000); + } + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.html b/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.html deleted file mode 100644 index fc69f75..0000000 --- a/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.html +++ /dev/null @@ -1 +0,0 @@ -

manage-candidates works!

diff --git a/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.scss b/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.scss deleted file mode 100644 index e69de29..0000000 diff --git a/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.ts b/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.ts deleted file mode 100644 index d207c1b..0000000 --- a/otpcasestudy-angular-app/src/app/components/admin/manage-candidates/manage-candidates.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-manage-candidates', - standalone: true, - imports: [], - templateUrl: './manage-candidates.component.html', - styleUrl: './manage-candidates.component.scss' -}) -export class ManageCandidatesComponent { - -} diff --git a/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.html b/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.html index 66abec0..d2878da 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.html +++ b/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.html @@ -60,7 +60,9 @@

Schedule New Exam

formControlName="key" required placeholder="10-char alphanumeric" + matTooltip="Exam Key is auto-generated and cannot be edited" /> + Auto-generated from Exam Title {{ getErrorMessage("key") }} @@ -195,6 +197,7 @@
Exam Sections
mat-raised-button color="primary" type="submit" + class="submit-btn" [disabled]="submitting || examForm.invalid" > diff --git a/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.scss b/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.scss index 7c64743..644a668 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.scss +++ b/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.scss @@ -92,12 +92,16 @@ height: 48px; display: flex; align-items: center; + justify-content: center; gap: 8px; font-weight: 500; - - mat-spinner { - margin-right: 8px; - } + position: relative; + } + .submit-btn mat-spinner { + margin: 0 !important; + position: static !important; + display: inline-flex; + vertical-align: middle; } .reset-btn { diff --git a/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.ts b/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.ts index 40b8c73..349ffd5 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/schedule-exam/schedule-exam.component.ts @@ -71,6 +71,15 @@ export class ScheduleExamComponent implements OnInit { ngOnInit(): void { this.examForm = this.createForm(); + this.examForm.get('title')?.valueChanges.subscribe((title: string) => { + if (title && title.trim().length > 0) { + const generatedKey = this.generateExamKey(title); + this.examForm.get('key')?.setValue(generatedKey, { emitEvent: false }); + } else { + this.examForm.get('key')?.setValue('', { emitEvent: false }); + } + }); + this.questionService.getAllCategories().subscribe({ next: (categories: any[]) => { this.categories = categories.map((c) => ({ @@ -83,8 +92,8 @@ export class ScheduleExamComponent implements OnInit { this.categoryMarksMap[c.categoryId] = c.marks; }); }, - error: () => { - this.snackBar.open('Failed to load categories', 'Close', { + error: (err) => { + this.snackBar.open(err?.error?.message || 'Failed to load categories', 'Close', { duration: 3000, }); }, @@ -102,7 +111,7 @@ export class ScheduleExamComponent implements OnInit { [Validators.required, Validators.min(0), Validators.max(100)], ], key: [ - '', + { value: '', disabled: true }, [Validators.required, Validators.pattern(/^[A-Za-z0-9]{10}$/)], ], startDate: ['', Validators.required], @@ -173,9 +182,9 @@ export class ScheduleExamComponent implements OnInit { this.availableQuestionCounts[index] = Array.from({ length: count }, (_, i) => i + 1); section.get('questionCount')?.enable(); }, - error: () => { + error: (err) => { this.availableQuestionCounts[index] = []; - this.snackBar.open('Failed to load question count', 'Close', { duration: 3000 }); + this.snackBar.open(err?.error?.mesage || 'Failed to load question count', 'Close', { duration: 3000 }); } }); } @@ -234,7 +243,7 @@ export class ScheduleExamComponent implements OnInit { if (this.examForm.valid) { this.submitting = true; - const formValue = this.examForm.value; + const formValue = this.examForm.getRawValue(); const startDateTime = new Date(formValue.startDate); const [hours, minutes] = formValue.startTime.split(':'); startDateTime.setHours(parseInt(hours, 10), parseInt(minutes, 10)); @@ -270,6 +279,8 @@ export class ScheduleExamComponent implements OnInit { })), }; + console.log(examPayload) + this.examService.scheduleExam(examPayload).subscribe({ next: () => { this.submitting = false; @@ -279,9 +290,9 @@ export class ScheduleExamComponent implements OnInit { }); this.resetForm(); }, - error: () => { + error: (err) => { this.submitting = false; - this.snackBar.open('Failed to schedule exam', 'Close', { + this.snackBar.open(err?.error?.message || 'Failed to schedule exam', 'Close', { duration: 3000, }); }, @@ -325,25 +336,22 @@ export class ScheduleExamComponent implements OnInit { removeSection(index: number): void { this.sections.removeAt(index); delete this.availableQuestionCounts[index]; - delete this.availableMarksMap[index]; // <-- changed + delete this.availableMarksMap[index]; } getErrorMessage(controlName: string): string { const control = this.examForm.get(controlName); - if (!control || !control.errors) return ''; - const errorMessages: { [key: string]: string } = { - required: 'This field is required', - min: `Minimum value is ${control.errors['min']?.min}`, - max: `Maximum value is ${control.errors['max']?.max}`, - minlength: `Minimum length is ${control.errors['minlength']?.requiredLength}`, - maxlength: `Maximum length is ${control.errors['maxlength']?.requiredLength}`, - pattern: 'Must be exactly 10 alphanumeric characters', - }; - for (const errorKey in errorMessages) { - if (control.hasError(errorKey)) { - return errorMessages[errorKey]; - } - } + if (control?.hasError('required')) return 'This field is required'; + if (control?.hasError('min')) + return `Minimum value is ${control.errors?.['min'].min}`; + if (control?.hasError('max')) + return `Maximum value is ${control.errors?.['max'].max}`; + if (control?.hasError('minlength')) + return `Minimum length is ${control.errors?.['minlength'].requiredLength}`; + if (control?.hasError('maxlength')) + return `Maximum length is ${control.errors?.['maxlength'].requiredLength}`; + if (control?.hasError('pattern')) + return 'Must be exactly 10 alphanumeric characters'; return ''; } @@ -352,4 +360,12 @@ export class ScheduleExamComponent implements OnInit { this.categories.find((c) => c.categoryId === id)?.categoryName || '' ); } + + // Generate a 10-character alphanumeric key based on the title and random chars + generateExamKey(title: string): string { + // Use a hash of the title and random string for uniqueness + const base = title.replace(/\s+/g, '').substring(0, 5).toUpperCase(); + const random = Math.random().toString(36).substring(2, 12).toUpperCase(); + return (base + random).substring(0, 10); + } } \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.scss b/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.scss index 14eafd5..ab1b674 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.scss +++ b/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.scss @@ -101,12 +101,18 @@ } .upload-button { - display: inline-flex; + min-width: 140px; + height: 44px; + display: flex; align-items: center; + justify-content: center; gap: 8px; + font-weight: 500; + position: relative; } - .upload-button mat-spinner { - width: 20px; - height: 20px; + margin: 0 !important; + position: static !important; + display: inline-flex; + vertical-align: middle; } diff --git a/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.ts b/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.ts index 41cf2b7..2b4f151 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/upload-questions/upload-questions.component.ts @@ -88,10 +88,10 @@ export class UploadQuestionsComponent { this.uploading = false; let errorMsg = 'Failed to upload question. Please try again'; if ( - errorResponse?.error?.error?.message && - typeof errorResponse?.error?.error?.message === 'string' + errorResponse?.error?.message && + typeof errorResponse?.error?.message === 'string' ) { - errorMsg = errorResponse.error?.error?.message; + errorMsg = errorResponse.error?.message; } this.snackBar.open(errorMsg, '', { duration: 5000 }); diff --git a/otpcasestudy-angular-app/src/app/components/admin/view-candidates/view-candidates.component.ts b/otpcasestudy-angular-app/src/app/components/admin/view-candidates/view-candidates.component.ts index 5ea0884..4563668 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/view-candidates/view-candidates.component.ts +++ b/otpcasestudy-angular-app/src/app/components/admin/view-candidates/view-candidates.component.ts @@ -83,7 +83,7 @@ export class ViewCandidatesComponent implements OnInit { next: ([attended, notAttended]) => { console.log('Candidates loaded: '); - this.candidates = [...attended, ...notAttended]; + this.candidates = [...(attended || []), ...(notAttended || [])]; this.loading = false; }, error: () => { diff --git a/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.html b/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.html new file mode 100644 index 0000000..06b013c --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.html @@ -0,0 +1,46 @@ +
+
+
+ +

NextStep Admin Portal

+

Online Test Platform Management

+
+
+
+ +
+
diff --git a/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.scss b/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.scss new file mode 100644 index 0000000..d5b0044 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.scss @@ -0,0 +1,98 @@ +.login-container { + display: flex; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.branding-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #3b1877; + color: #fff; + box-shadow: 2px 0 16px rgba(102, 126, 234, 0.08); + min-height: 100vh; +} + +.branding-content { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.brand-logo { + font-size: 80px; + color: #fff; + margin-bottom: 18px; + background: none; + border-radius: 0; + box-shadow: none; + padding: 0; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.brand-title { + font-size: 2.5rem; + font-weight: 700; + color: #fff; + margin-bottom: 12px; +} + +.brand-subtitle { + font-size: 1.3rem; + color: #fff; + margin-bottom: 0; +} + +.login-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #fff; +} + +.login-card { + width: 100%; + max-width: 400px; + padding: 32px 24px; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(25, 118, 210, 0.12); + background: #f8fafc; +} + +.form-field { + width: 100%; + margin-bottom: 18px; +} + +.login-button { + width: 100%; + height: 48px; + font-weight: 600; + font-size: 1.1rem; + margin-top: 8px; +} + +@media (max-width: 900px) { + .login-container { + flex-direction: column; + } + .branding-panel, .login-panel { + flex: unset; + width: 100%; + min-height: 200px; + } + .login-card { + max-width: 100%; + padding: 24px 8px; + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.ts b/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.ts new file mode 100644 index 0000000..8b61d55 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/admin-login/admin-login.component.ts @@ -0,0 +1,59 @@ +import { Component } from '@angular/core'; +import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { AuthAdminService } from '../../../services/auth-admin.service'; +import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-admin-login', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ], + templateUrl: './admin-login.component.html', + styleUrls: ['./admin-login.component.scss'] +}) +export class AdminLoginComponent { + loginForm = this.fb.group({ + email: ['', Validators.required], + password: ['', Validators.required] + }); + loading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthAdminService, + private router: Router, + private snackBar: MatSnackBar + ) { } + + login() { + if (this.loginForm.invalid) return; + this.loading = true; + const { email, password } = this.loginForm.value; + this.authService.login(email!, password!).subscribe({ + next: () => { + this.router.navigate(['/admin/dashboard']); + this.loading = false; + }, + error: (err) => { + this.snackBar.open(err?.error?.message || 'Login failed. Check credentials.', '', { duration: 5000 }); + this.loading = false; + } + }); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.html b/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.html new file mode 100644 index 0000000..4f35467 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.html @@ -0,0 +1,41 @@ +
+
+
+ +

NextStep Candidate Portal

+

Online Test Platform

+
+
+
+ +
+
diff --git a/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.scss b/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.scss new file mode 100644 index 0000000..d5b0044 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.scss @@ -0,0 +1,98 @@ +.login-container { + display: flex; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.branding-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #3b1877; + color: #fff; + box-shadow: 2px 0 16px rgba(102, 126, 234, 0.08); + min-height: 100vh; +} + +.branding-content { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.brand-logo { + font-size: 80px; + color: #fff; + margin-bottom: 18px; + background: none; + border-radius: 0; + box-shadow: none; + padding: 0; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.brand-title { + font-size: 2.5rem; + font-weight: 700; + color: #fff; + margin-bottom: 12px; +} + +.brand-subtitle { + font-size: 1.3rem; + color: #fff; + margin-bottom: 0; +} + +.login-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #fff; +} + +.login-card { + width: 100%; + max-width: 400px; + padding: 32px 24px; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(25, 118, 210, 0.12); + background: #f8fafc; +} + +.form-field { + width: 100%; + margin-bottom: 18px; +} + +.login-button { + width: 100%; + height: 48px; + font-weight: 600; + font-size: 1.1rem; + margin-top: 8px; +} + +@media (max-width: 900px) { + .login-container { + flex-direction: column; + } + .branding-panel, .login-panel { + flex: unset; + width: 100%; + min-height: 200px; + } + .login-card { + max-width: 100%; + padding: 24px 8px; + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.ts b/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.ts new file mode 100644 index 0000000..7615819 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/candidate-login/candidate-login.component.ts @@ -0,0 +1,67 @@ +import { Component } from '@angular/core'; +import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { ExamSessionService } from '../../../services/exam-session.service'; +import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-candidate-login', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ], + templateUrl: './candidate-login.component.html', + styleUrls: ['./candidate-login.component.scss'] +}) +export class CandidateLoginComponent { + loginForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required] + }); + loading = false; + + constructor( + private fb: FormBuilder, + private examSessionService: ExamSessionService, + private router: Router, + private snackBar: MatSnackBar + ) { } + + login() { + if (this.loginForm.invalid) return; + this.loading = true; + const { email, password } = this.loginForm.value; + this.examSessionService.startExamSession({ email, password }).subscribe({ + next: (res) => { + this.loading = false; + // Save response data to sessionStorage for exam window + if (res && res.data) { + sessionStorage.setItem('exam_session_data', JSON.stringify(res.data)); + } + this.router.navigate(['/exam']); + }, + error: (err) => { + this.loading = false; + let msg = err?.error?.message || 'Login failed. Check credentials or exam status.'; + if (msg.includes('Permission')) { + msg = 'You do not have permission to access this exam. Please check your exam key or contact support.'; + } + this.snackBar.open(msg, '', { duration: 5000 }); + } + }); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.html b/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.html new file mode 100644 index 0000000..c9562ce --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.html @@ -0,0 +1,24 @@ +
+ +
+ + NextStep +
+ +
+ +

Verifying your email...

+
+
+

{{ message }}

+ +
+
+
+
diff --git a/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.scss b/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.scss new file mode 100644 index 0000000..f80ffbe --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.scss @@ -0,0 +1,65 @@ +.container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: #f5f5f5; +} +mat-card { + padding: 40px; + text-align: center; +} +.center { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} +.verify-logo-bar { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +} +.verify-logo { + font-size: 72px; + color: #3b1877; + margin-bottom: 10px; + width: 72px; + height: 72px; + line-height: 72px; + display: flex; + align-items: center; + justify-content: center; +} +.verify-portal-name { + font-size: 2.3rem; + font-weight: 700; + color: #3b1877; + letter-spacing: 1px; + margin-bottom: 4px; +} +.verify-btn, +.prominent-btn { + font-size: 1.2rem; + padding: 16px 48px; + font-weight: 700; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(59, 24, 119, 0.08); + background: #3b1877 !important; + color: #fff !important; + margin-top: 12px; + cursor: pointer; +} + +.resend-btn { + font-size: 1.2rem; + padding: 16px 48px; + font-weight: 700; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(59, 24, 119, 0.08); + background: #187762 !important; + color: #fff !important; + margin-top: 12px; + cursor: pointer; +} diff --git a/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.ts b/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.ts new file mode 100644 index 0000000..837febb --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/email-verification/email-verification.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../../environments/environment'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'app-email-verification', + standalone: true, + imports: [CommonModule, MatCardModule, MatProgressSpinnerModule, MatIconModule], + templateUrl: './email-verification.component.html', + styleUrls: ['./email-verification.component.scss'] +}) +export class EmailVerificationComponent implements OnInit { + loading = true; + message = ''; + success = false; + + constructor( + private route: ActivatedRoute, + private http: HttpClient, + private router: Router + ) { } + + ngOnInit(): void { + const token = this.route.snapshot.queryParamMap.get('token'); + if (token) { + this.http.get(`${environment.apiBaseUrl}/auth/verify-email?token=${token}`).subscribe({ + next: (res: any) => { + this.message = res?.data || 'Email verified successfully! You can now log in.'; + this.success = true; + this.loading = false; + }, + error: (err) => { + this.message = err?.error?.message || 'Verification failed. The link may be invalid or expired.'; + this.success = false; + this.loading = false; + } + }); + } else { + this.message = 'No verification token found.'; + this.success = false; + this.loading = false; + } + } + goToLanding() { + this.router.navigate(['/landing']); + } + + resendVerificationLink() { + + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.html b/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.html new file mode 100644 index 0000000..b3a809d --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.html @@ -0,0 +1,48 @@ +
+ + NextStep +
+
+
+

Welcome to Your NextStep!

+

+ Take secure, modern online assessments and get instant results.
Join + thousands of candidates on their journey to success. +

+
+ +
+
+
+
+
+ verified_user +

Secure Exams

+

Your data and answers are protected with industry-standard security.

+
+
+ timer +

Real-Time Timer

+

Live countdown and auto-submit ensure a fair and smooth experience.

+
+
+ assessment +

Instant Results

+

Get your results as soon as the exam ends. No waiting, no hassle.

+
+
+ support_agent +

24/7 Support

+

Our team is here to help you at every step of your journey.

+
+
+ diff --git a/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.scss b/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.scss new file mode 100644 index 0000000..c5c0a60 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.scss @@ -0,0 +1,340 @@ +.landing-root { + display: flex; + height: 100vh; + background-color: #f5f5f5; +} +.branding-panel { + width: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + justify-content: center; + align-items: center; + text-align: center; +} +.branding-content .brand-logo { + font-size: 80px; + width: 80px; + height: 80px; + margin-bottom: 24px; +} +.branding-content .brand-title { + font-size: 2.5rem; + font-weight: 600; + margin: 0; +} +.branding-content .brand-subtitle { + font-size: 1.2rem; + opacity: 0.9; + margin-top: 12px; +} +.register-panel { + width: 50%; + display: flex; + justify-content: center; + align-items: center; +} +.register-card { + width: 400px; + padding: 24px; +} +.register-card h2 { + text-align: center; + font-size: 1.8rem; + font-weight: 500; + margin-bottom: 24px; +} +.form-field { + width: 100%; + margin-bottom: 20px; +} +.register-button { + width: 100%; + height: 48px; + font-size: 1rem; +} +.success-message { + margin-top: 1rem; + color: #388e3c; + font-weight: 600; + text-align: center; +} +.login-link { + margin-top: 1.5rem; + text-align: center; + .login-btn { + color: #667eea; + font-weight: 600; + margin-left: 8px; + text-decoration: underline; + cursor: pointer; + } +} +.logo-bar { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 32px 0 0 0; + background: transparent; +} +.main-logo { + font-size: 72px; + color: #667eea; + background: #fff; + border-radius: 50%; + box-shadow: 0 2px 12px rgba(25, 118, 210, 0.08); + padding: 12px; +} +.landing-hero { + min-height: 50vh; + padding-top: 0; +} +.hero-content { + margin-top: 0; +} +.hero-logo { + font-size: 64px; + margin-bottom: 16px; + color: #fff; +} +.hero-title { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 12px; +} +.hero-subtitle { + font-size: 1.2rem; + margin-bottom: 32px; + color: #e0e7ff; +} +.register-btn { + font-size: 1.1rem; + padding: 12px 36px; + margin-bottom: 18px; + font-weight: 600; + background: #7c4dff !important; + color: #fff !important; + border: none; + box-shadow: 0 2px 8px rgba(60, 30, 120, 0.08); + transition: background 0.2s; +} +.register-btn:hover, .register-btn:focus { + background: #5e35b1 !important; + color: #fff !important; +} +.login-link { + margin-top: 18px; + color: #e0e7ff; + font-size: 1rem; +} +.login-btn { + color: #fff; + font-weight: 600; + margin-left: 8px; + text-decoration: underline; +} +.features-row { + display: flex; + justify-content: center; + gap: 32px; + margin: 48px 0 0 0; + flex-wrap: wrap; +} +.feature-card { + background: #fff; + color: #4a5568; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(25, 118, 210, 0.08); + padding: 32px 24px; + min-width: 220px; + max-width: 300px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 24px; +} +.feature-card h3 { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 8px; +} +.feature-card p { + font-size: 1rem; + color: #6b7280; +} +.top-bar { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 32px 0 0 0; + background: transparent; + gap: 16px; +} +.main-logo { + font-size: 80px; + color: #3b1877 !important; + background: none !important; + border-radius: 0 !important; + box-shadow: none !important; + padding: 0 !important; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; +} +.portal-name { + font-size: 2.5rem; + font-weight: 700; + color: #3b1877 !important; + letter-spacing: 1px; +} +.hero-section { + min-height: 45vh; + background: #3b1877; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + padding: 48px 0 32px 0; +} +.hero-content { + text-align: center; + max-width: 700px; + margin: 0 auto; +} +.hero-title { + font-size: 2.8rem; + font-weight: 700; + margin-bottom: 18px; + color: #fff; +} +.hero-subtitle { + font-size: 1.25rem; + margin-bottom: 36px; + color: #e0e7ff; +} +.hero-actions { + display: flex; + justify-content: center; + gap: 24px; + margin-bottom: 12px; +} +.register-btn, .login-btn { + font-size: 1.1rem; + padding: 12px 36px; + font-weight: 600; +} +.features-row { + display: flex; + justify-content: center; + gap: 32px; + margin: 48px 0 0 0; + flex-wrap: wrap; +} +.feature-card { + background: #fff; + color: #4a5568; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(25, 118, 210, 0.08); + padding: 32px 24px; + min-width: 220px; + max-width: 300px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 24px; +} +.feature-card mat-icon { + font-size: 48px !important; + color: #3b1877 !important; + margin-bottom: 12px !important; + background: none !important; + border-radius: 0 !important; + box-shadow: none !important; + padding: 0 !important; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; +} +.feature-card h3 { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 8px; +} +.feature-card p { + font-size: 1rem; + color: #6b7280; +} +.footer { + width: 100%; + text-align: center; + padding: 24px 0 12px 0; + color: #667eea; + font-size: 1rem; + background: #f5f5f5; + margin-top: 48px; +} +.main-logo { + font-size: 56px; + color: #667eea; + background: none; + border-radius: 0; + box-shadow: none; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + overflow: visible; +} +.modal-logo { + font-size: 40px; + color: #667eea; + background: none; + border-radius: 0; + box-shadow: none; + padding: 0; + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: auto; + overflow: visible; +} +@media (max-width: 900px) { + .branding-panel { + display: none; + } + .register-panel { + width: 100%; + } + .register-card { + width: 90%; + max-width: 400px; + } + .features-row { + flex-direction: column; + align-items: center; + gap: 16px; + } + .feature-card { + min-width: unset; + max-width: 90vw; + padding: 20px 10px; + } + .hero-title { + font-size: 2rem; + } + .portal-name { + font-size: 1.2rem; + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.ts b/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.ts new file mode 100644 index 0000000..06646b6 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/landing/landing.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { RouterModule } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; + +@Component({ + selector: 'app-landing', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + RouterModule, + MatCardModule + ], + templateUrl: './landing.component.html', + styleUrls: ['./landing.component.scss'] +}) +export class LandingComponent { + currentYear = new Date().getFullYear(); + constructor(private router: Router) {} + + openRegistrationDialog() { + this.router.navigate(['/registration']); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.html b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.html new file mode 100644 index 0000000..b093461 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.html @@ -0,0 +1,112 @@ +
+
+
+ + NextStep +
+

Candidate Registration

+ +
+
+ + Name + + Name is required + + + Email + + Email is required + Enter a valid email + + + Mobile Number + + Mobile number is required + Enter a valid mobile number + + + College Name + + College name is required + + + Degree Name + + Degree name is required + + + City + + City is required + +
+ + +
+
+
+
+
\ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.scss b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.scss new file mode 100644 index 0000000..2ad3746 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.scss @@ -0,0 +1,160 @@ +.registration-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #3b1877; + padding: 20px; +} + +.registration-card { + width: 100%; + max-width: 500px; + padding: 40px 32px; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(25, 118, 210, 0.12); + background: #fff; + backdrop-filter: blur(10px); +} + +.logo-bar { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 16px; +} + +.logo { + font-size: 80px; + color: #3b1877; + background: none !important; + border-radius: 0 !important; + box-shadow: none !important; + padding: 0 !important; + margin-bottom: 8px; + width: auto !important; + height: auto !important; + display: block; +} + +.portal-name { + font-size: 2.5rem; + font-weight: 700; + color: #3b1877; + letter-spacing: 1px; +} + +.page-title { + text-align: center; + font-weight: 700; + margin-bottom: 16px; + color: #1a202c; + font-size: 1.75rem; +} + +.form-container { + margin-top: 24px; +} + +.registration-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-field { + width: 100%; +} + +.form-actions { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 24px; + flex-wrap: wrap; +} + +.form-actions button { + flex: 1 1 0; + min-width: 160px; + max-width: 220px; + height: 48px; + font-weight: 600; + font-size: 1rem; + margin: 0 4px; + border: none; + box-shadow: 0 2px 8px rgba(60, 30, 120, 0.08); + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.form-actions button[type="submit"] { + background: #3b1877 !important; + color: #fff !important; +} +.form-actions button[type="submit"]:hover, +.form-actions button[type="submit"]:focus { + background: #2a115a !important; + color: #fff !important; +} + +.form-actions button.go-home-btn { + background: #7c4dff !important; + color: #fff !important; +} +.form-actions button.go-home-btn:hover, +.form-actions button.go-home-btn:focus { + background: #5e35b1 !important; + color: #fff !important; +} + +.form-actions button[type="submit"]:disabled { + background: #b39ddb !important; + color: #eee !important; + cursor: not-allowed; + opacity: 1; +} + +@media (max-width: 600px) { + .registration-container { + padding: 16px; + } + + .registration-card { + padding: 24px 20px; + } + + .form-actions { + flex-direction: column; + gap: 12px; + } + + .form-actions button { + width: 100%; + } + + .page-title { + font-size: 1.5rem; + } + + .logo { + font-size: 60px; + } + + .portal-name { + font-size: 1.3rem; + } +} + +@media (max-width: 480px) { + .registration-card { + padding: 20px 16px; + } + + .registration-form { + gap: 16px; + } +} diff --git a/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.ts b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.ts new file mode 100644 index 0000000..06f3d3f --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.ts @@ -0,0 +1,95 @@ +import { Component } from '@angular/core'; +import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { CandidateRegistrationService } from '../../../services/candidate-registration.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-registration', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatDividerModule + ], + templateUrl: './registration.component.html', + styleUrls: ['./registration.component.scss'] +}) +export class RegistrationComponent { + registrationForm = this.fb.group({ + name: ['', Validators.required], + email: ['', [Validators.required, Validators.email]], + mobileNumber: ['', [Validators.required, Validators.pattern('^[0-9]{10,15}$')]], + collegeName: ['', Validators.required], + degreeName: ['', Validators.required], + city: ['', Validators.required] + }); + loading = false; + + constructor( + private fb: FormBuilder, + private candidateRegService: CandidateRegistrationService, + private snackBar: MatSnackBar, + private router: Router + ) { } + + register() { + if (this.registrationForm.invalid) return; + this.loading = true; + this.candidateRegService.register(this.registrationForm.value).subscribe({ + next: () => { + console.log(this.registrationForm.value); + this.loading = false; + this.snackBar.open('Registration successful! Please check your email to verify your account.', '', { duration: 5000 }); + this.router.navigate(['/landing']); + }, + error: (err) => { + this.loading = false; + console.log(this.registrationForm.value); + const errorMessage = err?.error?.message || 'Registration failed.'; + console.log(errorMessage); + + if (errorMessage.includes('already exists')) { + const snackBarRef = this.snackBar.open('This email is already registered but not verified.', 'Resend Verification', { + duration: 10000 + }); + snackBarRef.onAction().subscribe(() => { + this.resendVerification(); + }); + } else { + this.snackBar.open(errorMessage, '', { duration: 5000 }); + } + } + }); + } + + resendVerification() { + const email = this.registrationForm.get('email')?.value; + if (email) { + this.candidateRegService.resendVerification(email).subscribe({ + next: () => { + this.snackBar.open('A new verification link has been sent to your email.', 'OK', { duration: 5000 }); + }, + error: (err) => { + this.snackBar.open(err?.error?.message || 'Failed to resend verification link.', '', { duration: 5000 }); + } + }); + } + } + + goToLanding() { + this.router.navigate(['/landing']); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.html b/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.html new file mode 100644 index 0000000..e5238c4 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.html @@ -0,0 +1,290 @@ +
+ +
+ + NextStep +
+
+
+ Welcome, {{ exam.candidateName }} +
+
+ Exam: {{ exam.examName }} +
+
+
+
Exam starts in:
+
+ timer + {{ + countdownTimeLeft | duration + }} + +
+
+ +

Instructions:

+ +
+
+
+
+ +
+
+ {{ currentCategory.name }} +
+
+ Question {{ currentQuestionIndex + 1 }} of {{ totalQuestions }} +
+
+
+ {{ currentQuestion.text }} +
+
+
+
+ + + {{ opt }} + + +
+
+
+ + + + + +
+
+
+
+ + + + + + + + + + + +
+
+ +

Exam Result

+
+ Total Marks: {{ result.totalMarks }} +
+
Score: {{ result.score }}
+
+
+ {{ cat.category || cat.name }}: {{ cat.marks || cat.score }} +
+
+
+
+ {{ cat.name }}: {{ cat.score || cat.marks }} +
+
+
+ Status: + + {{ result.status }} + +
+
+ Status: + + {{ result.pass ? "Pass" : "Fail" }} + +
+ +
+
+
diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.scss b/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.scss new file mode 100644 index 0000000..07b0242 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.scss @@ -0,0 +1,702 @@ +.exam-window-root { + display: flex; + flex-direction: column; + min-height: 100vh; + height: 100vh; + background: #f5f5f5; + overflow: hidden; +} +.exam-top-bar { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + padding: 32px 0 0 0; + background: transparent; + position: relative; +} + +.navbar-spacer { + flex: 1; +} + +.navbar-submit-btn { + margin-right: 32px; + align-self: center; +} + +.exam-logo { + font-size: 48px; + color: #3b1877; + background: none; + border-radius: 0; + box-shadow: none; + padding: 0; + width: auto; + height: auto; + overflow: visible; +} +.exam-portal-name { + font-size: 2rem; + font-weight: 700; + color: #3b1877; + letter-spacing: 1px; +} +.timer-bar { + display: flex; + align-items: center; + justify-content: space-between; + background: #3b1877; + color: #fff; + font-size: 1.3rem; + padding: 16px 32px; + font-weight: 600; + letter-spacing: 1px; + gap: 0; + mat-icon { + margin-right: 10px; + font-size: 28px; + } +} +.timer-bar.red { + background: #e53935; + color: #fff; +} +.timer-info { + display: flex; + align-items: center; + gap: 10px; +} +.primary-btn { + background: #3b1877 !important; + color: #fff !important; + font-weight: 700; + font-size: 1.1rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(59, 24, 119, 0.08); +} +.exam-layout { + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; + gap: 48px; + padding: 12px 0; + height: 100%; + overflow: hidden; +} +.question-area { + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(59, 24, 119, 0.08); + padding: 40px 48px 40px 40px; + min-width: 350px; + max-width: 650px; + width: 100%; + max-height: 85vh; + height: 85vh; + display: flex; + flex-direction: column; + gap: 24px; + scrollbar-width: thin; + margin-bottom: 32px; +} + +.question-scroll { + height: 120px; + min-height: 100px; + max-height: 180px; + overflow-y: auto; + margin-bottom: 18px; +} + +.options-scroll { + min-height: 120px; + height: 220px; + max-height: none; + overflow-y: auto; + overflow-x: hidden; + margin-bottom: 18px; +} + +.question-actions { + margin-top: auto; + display: flex; + flex-wrap: wrap; + gap: 12px; + background: #fff; +} +.question-header { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 12px; +} +.question-text { + font-size: 1.15rem; + margin-bottom: 18px; +} +.result-header { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-bottom: 12px; +} +.result-container mat-card { + padding: 32px 24px; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(59, 24, 119, 0.08); + background: #fff; + text-align: center; +} +.finish-btn { + margin-top: 24px; + width: 100%; + font-size: 1.1rem; + font-weight: 700; + background: #3b1877 !important; + color: #fff !important; +} +.options-area { + width: 100%; +} +.options-area mat-radio-group { + width: 100%; + display: flex; + flex-direction: column; + gap: 14px; +} +.options-area mat-radio-button { + width: 100%; + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + text-align: left; + background: #f5f5f5; + border-radius: 8px; + padding: 12px 24px 12px 16px; + margin-bottom: 4px; + font-size: 1.08rem; + transition: background 0.2s; + min-height: 36px; + line-height: 1.4; + margin-right: 16px; +} +.options-area mat-radio-button.mat-radio-checked { + background: #e6e0f3; + color: #3b1877; +} +.result-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(59, 24, 119, 0.15); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} +.result-modal { + min-width: 350px; + max-width: 95vw; + box-shadow: 0 8px 32px rgba(59, 24, 119, 0.18); + border-radius: 18px; + background: #fff; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} +.result-modal mat-card { + width: 100%; + border-radius: 18px; + box-shadow: none; + padding: 36px 32px; +} +.category-header { + font-size: 1.2rem; + font-weight: 700; + color: #3b1877; + margin-bottom: 10px; + letter-spacing: 0.5px; +} +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(59, 24, 119, 0.15); + // z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} +.modal { + background: #fff; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(59, 24, 119, 0.18); + padding: 32px 28px; + min-width: 320px; + max-width: 95vw; + text-align: center; +} +.modal-actions { + display: flex; + gap: 18px; + justify-content: center; + margin-top: 24px; +} +.exam-header-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 32px 0 32px; + font-size: 1.15rem; + font-weight: 600; + color: #3b1877; +} +.exam-title { + font-size: 1.3rem; + font-weight: 700; + margin-bottom: 10px; + letter-spacing: 0.5px; +} +.candidate-name { + font-size: 1.1rem; + font-weight: 500; + color: #333; +} +.custom-exam-layout { + display: flex; + flex-direction: row; + gap: 32px; + justify-content: center; + align-items: flex-start; +} + +.navigator-box { + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(59, 24, 119, 0.1); + padding: 24px 16px 20px 16px; + min-width: 260px; + max-width: 320px; + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + margin-right: 0; + max-height: 80vh; + gap: 16px; + overflow-y: visible !important; +} + +.navigator-box::-webkit-scrollbar, +.navigator-box::-webkit-scrollbar-thumb { + display: none !important; + background: transparent !important; +} + +.categories-row { + display: flex; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; + justify-content: flex-start; + max-height: none; + overflow-y: visible; +} + +.categories-row::-webkit-scrollbar, +.categories-row::-webkit-scrollbar-thumb { + display: none !important; + background: transparent !important; +} + +.categories-row.categories-centered { + justify-content: center; + gap: 18px; + margin-bottom: 16px; +} + +.category-item { + background: #f5f5f5; + border-radius: 8px; + padding: 8px 14px 4px 14px; + text-align: center; + min-width: 80px; + margin-bottom: 8px; + transition: background 0.2s, color 0.2s; + border: 2px solid transparent; + font-size: 0.98rem; +} +.category-item.active-category { + background: #e6e0f3; + color: #3b1877; + border-color: #3b1877; + font-weight: 700; +} +.category-name { + font-size: 1rem; + font-weight: 600; + max-width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} +.category-question-count { + font-size: 0.85rem; + color: #666; + margin-top: 2px; +} + +.navigator-question-numbering { + font-size: 1.08rem; + font-weight: 600; + color: #3b1877; + margin: 8px 0 8px 0; + text-align: center; +} + +.navigator-legend { + margin-bottom: 12px; + .legend-row { + display: flex; + align-items: center; + gap: 24px; + margin-bottom: 4px; + justify-content: center; + font-size: 1rem; + } + .legend-pair { + display: flex; + align-items: center; + gap: 8px; + min-width: 120px; + } + .legend-box { + display: inline-block; + width: 20px; + height: 18px; + border-radius: 4px; + margin-right: 4px; + vertical-align: middle; + } + .not-seen { + background: #e0e0e0; + } + .seen-not-answered { + background: #e53935; + } + .answered { + background: #388e3c; + } + .review { + background: #ffb300; + } + .legend-label { + display: inline-block; + vertical-align: middle; + line-height: 1; + font-size: 1rem; + font-weight: 500; + color: #333; + } +} + +.navigator-instructions { + background: #f8f6fc; + border-radius: 8px; + padding: 12px 14px; + font-size: 0.98rem; + color: #3b1877; + margin-bottom: 12px; + margin-top: 0; + text-align: left; + ul { + margin: 8px 0 0 18px; + padding: 0; + font-size: 0.97rem; + color: #333; + } + li { + margin-bottom: 4px; + line-height: 1.4; + } +} + +.question-map { + display: flex; + flex-wrap: wrap; + gap: 8px 8px; + margin-top: 8px; + margin-bottom: 0; +} +.question-map-item { + width: 36px; + height: 36px; + border-radius: 8px; + background: #f5f5f5; + color: #3b1877; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.1rem; + margin-bottom: 0; + border: 2px solid transparent; + transition: background 0.2s, border 0.2s, color 0.2s; +} +.question-map-item.active-question { + background: #3b1877; + color: #fff; + border-color: #3b1877; +} +.question-map-item.seen { + background: #e6e0f3; + color: #3b1877; + border-color: #3b1877; +} +.question-map-item.not-answered { + background: #fff3e0; + color: #ff9800; + border-color: #ff9800; +} +.question-map-item.not-seen { + background: #f5f5f5; + color: #bbb; + border-color: #bbb; +} +.question-map-item.marked-review { + background: #ffe0e6; + color: #e53935; + border-color: #e53935; +} +.question-map-row-break { + flex-basis: 100%; + height: 0; +} + +.navigator-column { + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 260px; + max-width: 320px; + width: 100%; +} + +.navigator-side { + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 260px; + max-width: 320px; + width: 100%; +} + +.timer-bar-navigator { + display: flex; + align-items: center; + justify-content: center; + background: #3b1877; + color: #fff; + font-size: 1.3rem; + padding: 12px 0 8px 0; + font-weight: 600; + letter-spacing: 1px; + border-radius: 12px 12px 0 0; + margin-bottom: 8px; +} +.timer-bar-navigator.red { + background: #e53935; + color: #fff; +} +.timer-bar-navigator .timer-info { + display: flex; + align-items: center; + gap: 10px; +} + +.navigator-exam-name { + font-size: 1.15rem; + font-weight: 700; + color: #3b1877; + text-align: center; + margin-bottom: 12px; + margin-top: 2px; +} + +.navigator-logo-row { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-bottom: 10px; +} +.exam-logo { + font-size: 36px; + color: #3b1877; + background: none; + border-radius: 0; + box-shadow: none; + padding: 0; + width: auto; + height: auto; + overflow: visible; +} +.exam-portal-name { + font-size: 1.4rem; + font-weight: 700; + color: #3b1877; + letter-spacing: 1px; +} + +.submit-exam-outer-row { + display: flex; + justify-content: center; + align-items: center; + margin-left: 35px; + margin-top: 24px; + margin-bottom: 0; +} + +.waiting-room-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: #f8fafc; +} + +mat-card { + max-width: 480px; + margin: 0 auto; + padding: 32px 24px 24px 24px; + border-radius: 18px; + box-shadow: 0 8px 32px rgba(60, 24, 119, 0.10); + background: #fff; +} + +.waiting-logo-bar { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 18px; +} +.waiting-logo { + font-size: 72px; + color: #3b1877; + background: none !important; + border-radius: 0 !important; + box-shadow: none !important; + padding: 0 !important; + width: 72px; + height: 72px; + display: flex; + align-items: center; + justify-content: center; +} +.waiting-portal-name { + font-size: 2.2rem; + font-weight: 700; + color: #3b1877; + letter-spacing: 1px; + margin-top: 4px; +} +.waiting-header { + text-align: center; + margin-bottom: 18px; +} +.waiting-candidate-name { + font-size: 1.2rem; + color: #3b1877; + font-weight: 600; + margin-bottom: 2px; +} +.waiting-exam-name { + font-size: 1.1rem; + color: #5e35b1; + font-weight: 500; + margin-bottom: 8px; +} +.waiting-timer-row { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 18px; +} +.waiting-timer-label { + font-size: 1.1rem; + color: #3b1877; + font-weight: 500; + margin-bottom: 4px; +} +.waiting-timer { + display: flex; + align-items: center; + gap: 10px; + background: #ede7f6; + border-radius: 12px; + padding: 10px 24px; + font-size: 2.1rem; + color: #3b1877; + font-weight: 700; + box-shadow: 0 2px 8px rgba(60, 24, 119, 0.06); +} +.timer-icon { + font-size: 2.2rem; + color: #3b1877; +} +.waiting-timer-value { + font-family: 'Roboto Mono', monospace; + font-size: 2.1rem; + color: #3b1877; + font-weight: 700; +} +.waiting-instructions-title { + font-size: 1.2rem; + color: #3b1877; + font-weight: 600; + margin-top: 18px; + margin-bottom: 8px; +} +.waiting-instructions-list { + color: #333; + font-size: 1rem; + margin-left: 18px; + margin-bottom: 0; +} + +@media (max-width: 900px) { + .exam-layout { + flex-direction: column; + align-items: stretch; + gap: 16px; + padding: 16px 0; + } + .question-area { + min-width: unset; + max-width: unset; + padding: 16px; + max-height: 50vh; + } + .exam-portal-name { + font-size: 1.2rem; + } + .custom-exam-layout { + flex-direction: column; + gap: 16px; + } + .navigator-box { + min-width: unset; + max-width: unset; + padding: 12px 4px; + max-height: 50vh; + } +} diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.ts b/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.ts new file mode 100644 index 0000000..636a0bf --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/exam-window.component.ts @@ -0,0 +1,515 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ExamSessionService } from '../../services/exam-session.service'; +import { ExamDetails, ExamCategory, ExamSection, ExamQuestion } from '../../models/exam-session.model'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { interval, Subscription } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { FormsModule } from '@angular/forms'; +import { QuestionNavigatorComponent } from './question-navigator/question-navigator.component'; +import { DurationPipe } from '../../pipes/duration.pipe'; +import { MatDividerModule } from '@angular/material/divider'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-exam-window', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatRadioModule, + FormsModule, + QuestionNavigatorComponent, + DurationPipe, + MatDividerModule + ], + templateUrl: './exam-window.component.html', + styleUrls: ['./exam-window.component.scss'] +}) +export class ExamWindowComponent implements OnInit, OnDestroy { + exam: ExamDetails | null = null; + currentCategory: ExamCategory | null = null; + currentSection: ExamSection | null = null; + currentQuestionIndex: number = 0; + currentSectionIndex: number = 0; + currentCategoryIndex: number = 0; + answers: { [questionId: string]: { answer: string | null, markedForReview: boolean, seen?: boolean } } = {}; + seenQuestions: Set = new Set(); + timerSub?: Subscription; + countdownSub?: Subscription; + timeLeft: number = 0; // seconds for exam duration + countdownTimeLeft: number = 0; // seconds until exam starts + examStarted = false; + examEnded = false; + beforeStart = false; + result: any = null; + submitting = false; + tabSwitchCount = 0; + maxTabSwitches = 5; + warningShown = false; + private navigationWarned = false; + showCategoryModal = false; + showSubmitExamModal = false; + pendingCategorySubmit = false; + show15MinWarning = false; + show5MinWarning = false; + timerBarRed = false; + startExamEnabled = false; + + constructor( + private examService: ExamSessionService, + private snackBar: MatSnackBar, + private router: Router + ) { } + + ngOnInit() { + // Block inspect element and other security measures + window.addEventListener('contextmenu', this.blockContextMenu); + window.addEventListener('keydown', this.blockInspectKeys); + window.addEventListener('blur', this.handleTabSwitch); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + window.addEventListener('beforeunload', this.beforeUnloadHandler); + window.history.pushState(null, '', window.location.href); + window.addEventListener('popstate', this.handlePopState); + + // Debug logs for routing/session issues + const sessionData = this.examService.getExamSessionData(); + console.log('[ExamWindow] sessionData:', sessionData); + if (!sessionData || !sessionData.examKey) { + console.warn('[ExamWindow] Redirecting to /candidate-login due to missing sessionData or examKey'); + this.router.navigate(['/candidate-login']); + return; + } + // Load exam data from sessionStorage if available + const examData = this.examService.getExamDataFromSession(); + console.log('[ExamWindow] examData:', examData); + if (examData && examData.exam && examData.questions) { + this.exam = examData.exam; + this.answers = examData.answers || {}; + this.timeLeft = examData.timeLeft || 0; + this.examStarted = examData.examStarted || false; + this.beforeStart = !this.examStarted; + if (this.examStarted) { + this.startTimer(); + } else { + this.startCountdown(); + } + return; + } + // If not in session, fetch exam details from backend using examKey + const examKey = sessionData.examKey; + if (examKey) { + this.examService.fetchExamDataFromBackend(examKey).subscribe({ + next: (response: any) => { + this.exam = response.data; + if (!this.exam) { + this.snackBar.open('Could not retrieve exam details.', '', { duration: 3000 }); + this.router.navigate(['/candidate-login']); + return; + } + + const sessionData = this.examService.getExamSessionData(); + sessionData.examId = this.exam.examId; + this.examService.saveExamSessionData(sessionData); + + this.answers = {}; + + + const now = Date.now(); + const start = this.exam ? new Date(this.exam.startTime).getTime() : 0; + this.countdownTimeLeft = Math.max(0, Math.floor((start - now) / 1000)); + + this.beforeStart = true; + this.examStarted = false; + + this.saveExamStateToSession(); + this.startCountdown(); + }, + error: (err: any) => { + this.snackBar.open(err?.error?.message || 'Failed to load exam details.', '', { duration: 5000 }); + } + }); + } + } + + startCountdown() { + this.startExamEnabled = false; + this.countdownSub = interval(1000).subscribe(() => { + this.countdownTimeLeft--; + if (this.countdownTimeLeft <= 0) { + this.startExamEnabled = true; + this.countdownSub?.unsubscribe(); + } + }); + } + + saveExamStateToSession() { + this.examService.saveExamDataToSession({ + exam: this.exam, + questions: this.exam && this.exam.categories ? this.exam.categories : [], + answers: this.answers, + timeLeft: this.timeLeft, + examStarted: this.examStarted + }); + } + + onStartExam() { + // Fetch all questions/options/answers from backend using examKey + const sessionData = this.examService.getExamSessionData(); + const examKey = sessionData.examKey; + this.examService.fetchExamDataFromBackend(examKey).subscribe({ + next: (response: any) => { + this.exam = response.data; + if (!this.exam) { + this.snackBar.open('Could not start exam.', '', { duration: 3000 }); + return; + } + + const sessionData = this.examService.getExamSessionData(); + sessionData.examId = this.exam.examId; + this.examService.saveExamSessionData(sessionData); + + this.answers = {}; + this.timeLeft = this.exam ? Math.floor((new Date(this.exam.endTime).getTime() - Date.now()) / 1000) : 0; + this.examStarted = true; + this.beforeStart = false; + this.saveExamStateToSession(); + this.selectCategory(0); // Select first category + this.startTimer(); + }, + error: (err: any) => { + this.snackBar.open(err?.error?.message || 'Failed to fetch exam data.', '', { duration: 5000 }); + } + }); + } + + startTimer() { + this.timerSub = interval(1000).subscribe(() => { + this.timeLeft--; + // Save timeLeft to sessionStorage + const examData = this.examService.getExamDataFromSession(); + examData.timeLeft = this.timeLeft; + this.examService.saveExamDataToSession(examData); + if (this.timeLeft === 15 * 60) { + this.show15MinWarning = true; + } + if (this.timeLeft === 5 * 60) { + this.show5MinWarning = true; + this.timerBarRed = true; + } + if (this.timeLeft <= 0) { + this.submitExam(); + } + }); + } + + selectCategory(index: number) { + if (!this.exam) return; + const category = this.exam.categories[index]; + if (category.locked) return; + this.currentCategory = category; + this.currentCategoryIndex = index; + this.currentSectionIndex = 0; + this.selectSection(0); + } + + selectSection(index: number) { + if (!this.currentCategory) return; + this.currentSection = this.currentCategory.sections[index]; + this.currentSectionIndex = index; + this.currentQuestionIndex = 0; + // Mark first question as seen + const q = this.currentSection.questions[0]; + if (q) { + this.seenQuestions.add(q.id); + if (!this.answers[q.id]) { + this.answers[q.id] = { answer: null, markedForReview: false, seen: true }; + } else { + this.answers[q.id].seen = true; + } + } + } + + get currentQuestion(): ExamQuestion | null { + if (!this.currentSection) return null; + return this.currentSection.questions[this.currentQuestionIndex] || null; + } + + get totalQuestions(): number { + return this.currentSection ? this.currentSection.questions.length : 0; + } + + saveAnswer(answer: string | null) { + const q = this.currentQuestion; + if (!q || !this.answers) return; + this.answers[q.id] = { answer, markedForReview: false, seen: true }; + this.examService.saveAnswerToSession(q.id, answer, false); + } + + markForReview() { + const q = this.currentQuestion; + if (!q || !this.answers) return; + this.answers[q.id] = { answer: this.answers[q.id]?.answer || null, markedForReview: !this.answers[q.id]?.markedForReview, seen: true }; + this.examService.saveAnswerToSession(q.id, this.answers[q.id].answer, this.answers[q.id].markedForReview); + } + + clearAnswer() { + const q = this.currentQuestion; + if (!q) return; + this.answers[q.id] = { answer: null, markedForReview: false }; + } + + goToQuestion(index: number) { + this.currentQuestionIndex = index; + const q = this.currentSection?.questions[index]; + if (q) { + this.seenQuestions.add(q.id); + if (!this.answers[q.id]) { + this.answers[q.id] = { answer: null, markedForReview: false, seen: true }; + } else { + this.answers[q.id].seen = true; + } + } + } + + nextQuestion() { + if (this.currentSection && this.currentQuestionIndex < this.currentSection.questions.length - 1) { + this.goToQuestion(this.currentQuestionIndex + 1); + } + } + + prevQuestion() { + if (this.currentSection && this.currentQuestionIndex > 0) { + this.goToQuestion(this.currentQuestionIndex - 1); + } + } + + openCategoryModal() { + this.showCategoryModal = true; + } + closeCategoryModal() { + this.showCategoryModal = false; + } + openSubmitExamModal(event?: MouseEvent) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + this.showSubmitExamModal = true; + } + closeSubmitExamModal() { + this.showSubmitExamModal = false; + } + + + submitExam(autoSubmit = false) { + if (this.submitting) return; + this.submitting = true; + this.examService.submitExamPayloadToBackend().subscribe({ + next: () => { + this.snackBar.open('Exam submitted successfully.', '', { duration: 3000 }); + this.examEnded = true; + // Fetch result using examId + let examId: number | undefined = undefined; + if (this.exam && (this.exam as any).id) { + examId = (this.exam as any).id; + } else if (this.exam && (this.exam as any).examId) { + examId = (this.exam as any).examId; + } else { + const sessionData = this.examService.getExamSessionData(); + if (sessionData && sessionData.examId) { + examId = sessionData.examId; + } + } + if (examId) { + this.examService.getResult(examId).subscribe({ + next: (result: any) => { + this.result = result; + }, + error: (err: any) => { + this.snackBar.open(err?.error?.message || 'Failed to load result.', '', { duration: 3000 }); + } + }); + } + this.submitting = false; + }, + error: (err: any) => { + this.snackBar.open(err?.error?.message || 'Failed to submit exam.', '', { duration: 3000 }); + this.submitting = false; + } + }); + } + + loadResult() { + const sessionData = this.examService.getExamSessionData(); + if (!sessionData || !sessionData.examId) { + this.snackBar.open('Exam ID not found in session.', '', { duration: 3000 }); + return; + } + this.examService.getResult(sessionData.examId).subscribe({ + next: (result) => { + this.result = result; + }, + error: (err) => { + this.snackBar.open(err?.error?.message || 'Failed to load result.', '', { duration: 3000 }); + } + }); + } + + submitCategory() { + // Lock the current category and save answers to sessionStorage + if (!this.currentCategory) return; + // Use id or categoryId for category identification + const currentCatId = (this.currentCategory as any).id || (this.currentCategory as any).categoryId; + if (this.exam && this.exam.categories) { + const cat = this.exam.categories.find((c: any) => (c.id || c.categoryId) === currentCatId); + if (cat) { + cat.locked = true; + } + } + // Save updated exam data to sessionStorage + this.examService.saveExamDataToSession({ + exam: this.exam, + questions: this.exam && this.exam.categories ? this.exam.categories : [], + answers: this.answers, + timeLeft: this.timeLeft, + examStarted: this.examStarted + }); + // Optionally, move to the next category if available + if (this.exam && this.exam.categories) { + const nextIndex = this.exam.categories.findIndex((c: any) => (c.id || c.categoryId) === currentCatId) + 1; + if (nextIndex < this.exam.categories.length) { + this.selectCategory(nextIndex); + } + } + } + + blockContextMenu = (e: Event) => { + e.preventDefault(); + }; + + blockInspectKeys = (e: KeyboardEvent) => { + if ( + e.key === 'F12' || + (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'J' || e.key === 'C')) || + (e.ctrlKey && e.key === 'U') + ) { + e.preventDefault(); + }; + }; + + listerForUserGesture() { + const triggerFullScreen = () => { + this.enterFullScreen(); + window.removeEventListener('click', triggerFullScreen); + window.removeEventListener('keydown', triggerFullScreen); + window.removeEventListener('mousemove', triggerFullScreen); + } + + window.addEventListener('click', triggerFullScreen); + window.addEventListener('keydown', triggerFullScreen); + window.addEventListener('mousemove', triggerFullScreen); + } + + enterFullScreen() { + const elem = document.documentElement; + if (elem.requestFullscreen) { + elem.requestFullscreen(); + } else if ((elem).webkitRequestFullscreen) { + (elem).webkitRequestFullscreen(); + } else if ((elem).msRequestFullscreen) { + (elem).msRequestFullscreen(); + } + } + + exitFullScreen() { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else if ((document).webkitExitFullscreen) { + (document).webkitExitFullscreen(); + } else if ((document).msExitFullScreen) { + (document).msExitFullScreen(); + } + } + + handleFullScreenChange = () => { + if (!document.fullscreenElement && this.examStarted && !this.examEnded) { + this.tabSwitchCount++; + this.showTabSwitchWarning(); + this.enterFullScreen(); + } + } + + + handleTabSwitch = () => { + this.tabSwitchCount++; + this.showTabSwitchWarning(); + }; + + handleVisibilityChange = () => { + if (document.hidden) { + this.tabSwitchCount++; + this.showTabSwitchWarning(); + } + }; + + showTabSwitchWarning() { + if (this.tabSwitchCount < this.maxTabSwitches) { + this.snackBar.open( + `Warning: You have switched tabs/windows ${this.tabSwitchCount} time(s). You can only do this ${this.maxTabSwitches} times.`, + '', + { duration: 5000, panelClass: 'warn-snackbar' } + ); + } else if (this.tabSwitchCount === this.maxTabSwitches) { + this.snackBar.open('You have reached the maximum number of tab/window switches. The exam will be submitted.', '', { duration: 5000, panelClass: 'warn-snackbar' }); + this.submitExam(true); + } + } + + finishExam() { + localStorage.removeItem('jwt_token'); + this.router.navigate(['/landing']); + } + + beforeUnloadHandler = (event: BeforeUnloadEvent) => { + if (this.examStarted) { + event.preventDefault(); + event.returnValue = 'Are you sure you want to leave the exam? Your progress may be lost.'; + return event.returnValue; + } + return undefined; + }; + + handlePopState = (event: PopStateEvent) => { + if (this.examStarted && !this.navigationWarned) { + this.navigationWarned = true; + window.history.pushState(null, '', window.location.href); + alert('Warning: Navigating away will submit your exam or lose your progress!'); + setTimeout(() => { this.navigationWarned = false; }, 1000); + } + }; + + close15MinWarning() { this.show15MinWarning = false; } + close5MinWarning() { this.show5MinWarning = false; } + + confirmSubmitExam() { + this.closeSubmitExamModal(); + this.submitExam(); + } + + ngOnDestroy() { + window.removeEventListener('contextmenu', this.blockContextMenu); + window.removeEventListener('keydown', this.blockInspectKeys); + window.removeEventListener('blur', this.handleTabSwitch); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + window.removeEventListener('beforeunload', this.beforeUnloadHandler); + window.removeEventListener('popstate', this.handlePopState); + if (this.timerSub) this.timerSub.unsubscribe(); + if (this.countdownSub) this.countdownSub.unsubscribe(); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.html b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.html new file mode 100644 index 0000000..3f1f98b --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.html @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.scss b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.scss new file mode 100644 index 0000000..1b2cd0d --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.scss @@ -0,0 +1,49 @@ +.navigator-root { + display: flex; + flex-direction: column; + align-items: center; + .question-map { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + button { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + font-weight: 600; + position: relative; + &.not-seen { background: #e0e0e0; color: #333; } + &.seen-not-answered { background: #e53935; color: #fff; } + &.answered { background: #388e3c; color: #fff; } + &.review { background: #ffb300; color: #fff; } + &.current { border: 2px solid #1976d2; } + .status-icon { + font-size: 16px; + position: absolute; + bottom: 2px; + right: 2px; + width: 16px; + height: 16px; + line-height: 16px; + } + } + } + .legend { + display: flex; + gap: 12px; + font-size: 0.9rem; + .not-seen, .seen-not-answered, .answered, .review { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 4px; + margin-right: 4px; + } + .not-seen { background: #e0e0e0; } + .seen-not-answered { background: #e53935; } + .answered { background: #388e3c; } + .review { background: #ffb300; } + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.ts b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.ts new file mode 100644 index 0000000..3ee20e5 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.ts @@ -0,0 +1,28 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { ExamQuestion } from '../../../models/exam-session.model'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'app-question-navigator', + standalone: true, + imports: [CommonModule, MatIconModule], + templateUrl: './question-navigator.component.html', + styleUrls: ['./question-navigator.component.scss'] +}) +export class QuestionNavigatorComponent { + @Input() questions: ExamQuestion[] = []; + @Input() answers: { [questionId: string]: { answer: string | null, markedForReview: boolean, seen?: boolean } } = {}; + @Input() currentIndex: number = 0; + @Input() seenQuestions: Set = new Set(); + @Output() jumpTo = new EventEmitter(); + + getStatus(index: number): 'not-seen' | 'seen-not-answered' | 'answered' | 'review' { + const q = this.questions[index]; + if (!q) return 'not-seen'; + if (this.answers[q.id]?.answer) return 'answered'; + if (this.answers[q.id]?.markedForReview) return 'review'; + if (this.seenQuestions.has(q.id)) return 'seen-not-answered'; + return 'not-seen'; + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/guards/admin-auth-guard.ts b/otpcasestudy-angular-app/src/app/guards/admin-auth-guard.ts new file mode 100644 index 0000000..a0177c4 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/guards/admin-auth-guard.ts @@ -0,0 +1,22 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; + +function isTokenExpired(token: string): boolean { + if (!token) return true; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.exp * 1000 < Date.now(); + } catch { + return true; + } +} + +export const AdminAuthGuard: CanActivateFn = (route, state) => { + const token = localStorage.getItem('jwt_token'); + if (!token || isTokenExpired(token)) { + const router = inject(Router); + router.navigate(['/admin-login']); + return false; + } + return true; +}; \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/guards/exam-exit.guard.ts b/otpcasestudy-angular-app/src/app/guards/exam-exit.guard.ts new file mode 100644 index 0000000..6c77770 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/guards/exam-exit.guard.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core'; +import { CanDeactivate } from '@angular/router'; +import { ExamWindowComponent } from '../components/exam-window/exam-window.component'; + +@Injectable({ providedIn: 'root' }) +export class ExamExitGuard implements CanDeactivate { + canDeactivate(component: ExamWindowComponent): boolean { + if (component.examStarted) { + return confirm('Are you sure you want to leave the exam? Your progress may be lost.'); + } + return true; + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/guards/login.guard.ts b/otpcasestudy-angular-app/src/app/guards/login.guard.ts new file mode 100644 index 0000000..686b7c2 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/guards/login.guard.ts @@ -0,0 +1,14 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; + +export const LoginGuard: CanActivateFn = (route, state) => { + const loggedIn = localStorage.getItem('otp_admin_logged_in') === 'true'; + const router = inject(Router); + + if (loggedIn) { + router.navigate(['/admin/dashboard']); + return false; // Prevent access to the login page + } + + return true; // Allow access to the login page +}; \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/models/basic-response.model.ts b/otpcasestudy-angular-app/src/app/models/basic-response.model.ts new file mode 100644 index 0000000..861d93f --- /dev/null +++ b/otpcasestudy-angular-app/src/app/models/basic-response.model.ts @@ -0,0 +1,5 @@ +export interface BasicResponse { + status: string; + data: T; + error: any; +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/models/candidate-registration.model.ts b/otpcasestudy-angular-app/src/app/models/candidate-registration.model.ts new file mode 100644 index 0000000..e37ae12 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/models/candidate-registration.model.ts @@ -0,0 +1,8 @@ +export interface CandidateRegistration { + name: string; + email: string; + mobileNumber: string; + collegeName: string; + degreeName: string; + city: string; +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/models/candidate.model.ts b/otpcasestudy-angular-app/src/app/models/candidate.model.ts index 6570f06..5fb9c4e 100644 --- a/otpcasestudy-angular-app/src/app/models/candidate.model.ts +++ b/otpcasestudy-angular-app/src/app/models/candidate.model.ts @@ -1,5 +1,5 @@ export interface Candidate { - id: string; + id: number; name: string; email: string; phone: string; diff --git a/otpcasestudy-angular-app/src/app/models/exam-result.model.ts b/otpcasestudy-angular-app/src/app/models/exam-result.model.ts new file mode 100644 index 0000000..e9caf9c --- /dev/null +++ b/otpcasestudy-angular-app/src/app/models/exam-result.model.ts @@ -0,0 +1,5 @@ +export interface ExamResult { + totalMarks: number; + categoryResults: { category: string; marks: number }[]; + pass: boolean; +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/models/exam-session.model.ts b/otpcasestudy-angular-app/src/app/models/exam-session.model.ts new file mode 100644 index 0000000..86d82e0 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/models/exam-session.model.ts @@ -0,0 +1,32 @@ +export interface ExamSession { + token: string; + candidateId: number; + examId: number; +} + +export interface ExamDetails { + examId: number; + candidateName: string; + examName: string; + categories: ExamCategory[]; + startTime: string; + endTime: string; +} + +export interface ExamCategory { + name: string; + sections: ExamSection[]; + locked: boolean; +} + +export interface ExamSection { + name: string; + questions: ExamQuestion[]; +} + +export interface ExamQuestion { + id: string; + text: string; + options: string[]; + answer?: string; +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/models/exam.model.ts b/otpcasestudy-angular-app/src/app/models/exam.model.ts index fb5388f..b3af632 100644 --- a/otpcasestudy-angular-app/src/app/models/exam.model.ts +++ b/otpcasestudy-angular-app/src/app/models/exam.model.ts @@ -1,25 +1,17 @@ export interface Exam { - id: string; + id: number; title: string; - category: string; - startTime: Date; - endTime: Date; - totalQuestions: number; - totalMarks: number; - cutoffMarks: number; + startDateTime: Date; + endDateTime: Date; + status: string; totalCandidates: number; - status: 'upcoming' | 'ongoing' | 'ended'; - createdAt: Date; - updatedAt: Date; } -/** - * Represents one section (category+marks) in the exam. - */ + export interface ExamSection { questionCategory: QuestionCategory; numberOfQuestions: number; - marks: number; // marks per question in this section + marks: number; } export interface QuestionCategory { diff --git a/otpcasestudy-angular-app/src/app/pipes/duration.pipe.ts b/otpcasestudy-angular-app/src/app/pipes/duration.pipe.ts new file mode 100644 index 0000000..eef3f20 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/pipes/duration.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'duration', + standalone: true +}) +export class DurationPipe implements PipeTransform { + transform(value: number): string { + const minutes = Math.floor(value / 60); + const seconds = value % 60; + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/services/auth-admin.service.ts b/otpcasestudy-angular-app/src/app/services/auth-admin.service.ts new file mode 100644 index 0000000..2ebd7b5 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/services/auth-admin.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class AuthAdminService { + constructor(private http: HttpClient) { } + + login(email: string, password: string) { + return this.http.post(`${environment.apiBaseUrl}/auth/admin-login`, { email, password }).pipe( + tap((res) => { + const token = res?.data?.jwtToken || res?.data || res?.jwtToken || res; + if (token) { + localStorage.setItem('jwt_token', token); + } + }) + ); + } + + logout() { + localStorage.removeItem('jwt_token'); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts b/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts new file mode 100644 index 0000000..036e94e --- /dev/null +++ b/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + const token = localStorage.getItem('jwt_token'); + if (token) { + const cloned = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); + return next.handle(cloned); + } + return next.handle(req); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts b/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts new file mode 100644 index 0000000..77dd26a --- /dev/null +++ b/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { CandidateRegistration } from '../models/candidate-registration.model'; +import { tap } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class CandidateRegistrationService { + constructor(private http: HttpClient) { } + + register(candidate: any) { + return this.http.post(`${environment.apiBaseUrl}/candidate`, candidate).pipe( + tap((res) => { + const token = res?.data?.jwtToken || res?.data || res?.jwtToken || res; + if (token) { + localStorage.setItem('jwt_token', token); + } + }) + ); + } + + + resendVerification(email: string): Observable { + return this.http.post( + `${environment.apiBaseUrl}/auth/send-verification-link?email=${encodeURIComponent(email)}`, + {} + ); + } + + + logout() { + localStorage.removeItem('jwt_token'); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/services/candidate.service.ts b/otpcasestudy-angular-app/src/app/services/candidate.service.ts index 989efe1..193df04 100644 --- a/otpcasestudy-angular-app/src/app/services/candidate.service.ts +++ b/otpcasestudy-angular-app/src/app/services/candidate.service.ts @@ -1,128 +1,60 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { map, Observable } from 'rxjs'; import { environment } from '../../environments/environment'; import { Candidate, AbsentCandidate } from '../models/candidate.model'; @Injectable({ - providedIn: 'root' + providedIn: 'root' }) export class CandidateService { - private apiUrl = `${environment.apiBaseUrl}/candidates`; + private apiUrl = `${environment.apiBaseUrl}/admin`; + + constructor(private http: HttpClient) { } + + // Get all candidates + getCandidates(): Observable { + return this.http.get(this.apiUrl); + } + + // Get candidates by exam ID + getCandidatesByExam(examId: string): Observable { + return this.http.get(`${this.apiUrl}/exam/${examId}/candidate`); + } + + // Get candidates by exam ID and status + getCandidatesByExamAndStatus(examId: number, status: string): Observable { + const url = `${this.apiUrl}/exam/${examId}/candidate?candidateStatus=${status}`; + return this.http.get(url).pipe( + map((res) => res?.data || []) + ) + } + + // Get candidate by ID + getCandidateById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + // Update candidate status + updateCandidateStatus(id: string, status: Candidate['status']): Observable { + return this.http.patch(`${this.apiUrl}/${id}/status`, { status }); + } + + // Update candidate details (including isActive status) + updateCandidate(candidate: Partial): Observable { + return this.http.put(`${this.apiUrl}/${candidate.id}`, candidate); + } + + // Get absent candidates + getAbsentCandidates(): Observable { + return this.http.get(`${this.apiUrl}/absent`); + } + + // Download absent candidates as CSV + downloadAbsentCandidates(): Observable { + return this.http.get(`${this.apiUrl}/absent/download`, { + responseType: 'blob' + }); + } - constructor(private http: HttpClient) { } - - // Get all candidates - getCandidates(): Observable { - return this.http.get(this.apiUrl); - } - - // Get candidates by exam ID - getCandidatesByExam(examId: string): Observable { - return this.http.get(`${this.apiUrl}/exam/${examId}`); - } - - // Get candidate by ID - getCandidateById(id: string): Observable { - return this.http.get(`${this.apiUrl}/${id}`); - } - - // Update candidate status - updateCandidateStatus(id: string, status: Candidate['status']): Observable { - return this.http.patch(`${this.apiUrl}/${id}/status`, { status }); - } - - // Update candidate details (including isActive status) - updateCandidate(candidate: Partial): Observable { - return this.http.put(`${this.apiUrl}/${candidate.id}`, candidate); - } - - // Get absent candidates - getAbsentCandidates(): Observable { - return this.http.get(`${this.apiUrl}/absent`); - } - - downloadAbsentCandidates(): Observable { - return this.http.get(`${this.apiUrl}/absent/download`, { - responseType: 'blob' - }); - } - - // Mock data for development - getMockCandidates(): Candidate[] { - return [ - { - id: '1', - name: 'John Doe', - email: 'john.doe@example.com', - phone: '+1234567890', - examId: '1', - examTitle: 'JavaScript Fundamentals', - status: 'attended', - isActive: true, - registeredAt: new Date('2024-01-10T10:00:00'), - attendedAt: new Date('2024-01-15T10:30:00') - }, - { - id: '2', - name: 'Jane Smith', - email: 'jane.smith@example.com', - phone: '+1234567891', - examId: '1', - examTitle: 'JavaScript Fundamentals', - status: 'attended', - isActive: true, - registeredAt: new Date('2024-01-10T11:00:00'), - attendedAt: new Date('2024-01-15T10:15:00') - }, - { - id: '3', - name: 'Bob Johnson', - email: 'bob.johnson@example.com', - phone: '+1234567892', - examId: '1', - examTitle: 'JavaScript Fundamentals', - status: 'not_attended', - isActive: false, - registeredAt: new Date('2024-01-10T12:00:00') - }, - { - id: '4', - name: 'Alice Brown', - email: 'alice.brown@example.com', - phone: '+1234567893', - examId: '2', - examTitle: 'Angular Development', - status: 'registered', - isActive: true, - registeredAt: new Date('2024-01-12T14:00:00') - }, - { - id: '5', - name: 'Charlie Wilson', - email: 'charlie.wilson@example.com', - phone: '+1234567894', - examId: '2', - examTitle: 'Angular Development', - status: 'registered', - isActive: true, - registeredAt: new Date('2024-01-12T15:00:00') - } - ]; - } - - getMockAbsentCandidates(): AbsentCandidate[] { - return [ - { - id: '3', - name: 'Bob Johnson', - email: 'bob.johnson@example.com', - phone: '+1234567892', - examId: '1', - examTitle: 'JavaScript Fundamentals', - examEndTime: new Date('2024-01-15T12:00:00'), - registeredAt: new Date('2024-01-10T12:00:00') - } - ]; - } } \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/services/exam-session.service.ts b/otpcasestudy-angular-app/src/app/services/exam-session.service.ts new file mode 100644 index 0000000..0589eec --- /dev/null +++ b/otpcasestudy-angular-app/src/app/services/exam-session.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ExamResult } from '../models/exam-result.model'; +import { environment } from '../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class ExamSessionService { + private apiUrl = `${environment.apiBaseUrl}`; + constructor(private http: HttpClient) { } + + startExamSession(payload: any): Observable { + return this.http.post(`${this.apiUrl}/auth/exam-login`, payload); + } + + getExamSessionData(): any { + return JSON.parse(sessionStorage.getItem('exam_session_data') || '{}'); + } + + saveExamSessionData(data: any) { + sessionStorage.setItem('exam_session_data', JSON.stringify(data)); + } + + saveExamDataToSession(data: any) { + sessionStorage.setItem('exam_data', JSON.stringify(data)); + } + + getExamDataFromSession(): any { + return JSON.parse(sessionStorage.getItem('exam_data') || '{}'); + } + + fetchExamDataFromBackend(examKey: string): Observable { + return this.http.get(`${this.apiUrl}/exam/start?examKey=${encodeURIComponent(examKey)}`); + } + + saveAnswerToSession(questionId: string, answer: any, markedForReview: boolean) { + const examData = this.getExamDataFromSession(); + if (!examData.answers) examData.answers = {}; + examData.answers[questionId] = { answer, markedForReview }; + this.saveExamDataToSession(examData); + } + + getAnswersFromSession() { + const examData = this.getExamDataFromSession(); + return examData.answers || {}; + } + + submitExamPayloadToBackend(): Observable { + const examData = this.getExamDataFromSession(); + + const submitCategoryList = (examData.questions || []).map((cat: any) => { + const categoryQuestion: { [key: string]: any } = {}; + (cat.questions || []).forEach((q: any) => { + if (examData.answers && examData.answers[q.id]) { + categoryQuestion[q.id] = examData.answers[q.id].answer; + } + }); + return { + categoryId: cat.id, + categoryName: cat.categoryName, + categoryQuestion + }; + }); + const payload = { + examId: examData.exam?.id || examData.exam?.examId, + candidateId: examData.exam?.candidateId, + submitCategoryList + }; + return this.http.post(`${this.apiUrl}/result/submit-exam`, payload); + } + + getResult(examId: number): Observable { + return this.http.get(`${this.apiUrl}/result/view-result/${examId}`); + } + + logout() { + localStorage.removeItem('jwt_token'); + sessionStorage.clear(); + } +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/services/exam.service.ts b/otpcasestudy-angular-app/src/app/services/exam.service.ts index 7d9f957..7b6e789 100644 --- a/otpcasestudy-angular-app/src/app/services/exam.service.ts +++ b/otpcasestudy-angular-app/src/app/services/exam.service.ts @@ -1,104 +1,77 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { map, retry, delay } from 'rxjs'; import { environment } from '../../environments/environment.prod'; import { Exam, ExamSchedule, ExamResult } from '../models/exam.model'; +import { BasicResponse } from '../models/basic-response.model'; @Injectable({ providedIn: 'root' }) export class ExamService { - private apiUrl = `${environment.apiBaseUrl}/exams`; + private apiUrl = `${environment.apiBaseUrl}`; constructor(private http: HttpClient) { } - // Get all exams - getExams(): Observable { - return this.http.get(this.apiUrl); + // Get scheduled Exams + getScheduledExams(): Observable { + return this.http.get(`${this.apiUrl}/admin/exam/scheduled-exam`) + .pipe( + map((res) => { + return res?.data?.map((exam: any) => { + return { + ...exam, + id: exam.id ?? exam.examId, + startDateTime: new Date(exam.startTime), + endDateTime: new Date(exam.endTime) + } + }) ?? []; + }) + ) } - // Get exam by ID - getExamById(id: string): Observable { - return this.http.get(`${this.apiUrl}/${id}`); + // Get active exam count + getActiveExamCount(): Observable { + return this.http.get(`${this.apiUrl}/admin/exam/count/active-exam`) + .pipe(map((res) => res.data), + retry(2), + delay(1000) + ) } - // Schedule new exam - scheduleExam(examSchedule: ExamSchedule): Observable { - return this.http.post(this.apiUrl, examSchedule); + // Get total active candidates + getTotalActiveCandidates(): Observable { + return this.http.get(`${this.apiUrl}/candidate/count-active`) + .pipe(map(res => res.data), + retry(2), + delay(1000)); } - // Update exam - updateExam(id: string, exam: Partial): Observable { - return this.http.put(`${this.apiUrl}/${id}`, exam); + // Get all completed exams + getCompletedExams(): Observable { + return this.http.get(`${this.apiUrl}/admin/exam/completed-exam`) + .pipe(map((res) => { + console.log("Loaded Completed Exams: ", res.data); + return res.data.map((e: any) => ({ + ...e, + id: e.id ?? e.examId, + startDateTime: new Date(e.startTime), + endDateTime: new Date(e.endTime) + })) + }) + ); } - // Delete exam - deleteExam(id: string): Observable { - return this.http.delete(`${this.apiUrl}/${id}`); - } - - // Get exam results - getExamResults(examId: string): Observable { - return this.http.get(`${this.apiUrl}/${examId}/results`); - } - - // Download exam results as CSV - downloadResults(examId: string): Observable { - return this.http.get(`${this.apiUrl}/${examId}/results/download`, { + // Download results PDF for a specific exam + downloadResults(examId: number): Observable { + return this.http.get(`${this.apiUrl}/result/download-result/${examId}`, { responseType: 'blob' }); } - // Get exam statistics - getExamStats(examId: string): Observable { - return this.http.get(`${this.apiUrl}/${examId}/stats`); + scheduleExam(examSchedule: any): Observable { + return this.http.post(`${this.apiUrl}/admin/exam`, examSchedule) } - // Mock data for development - getMockExams(): Exam[] { - return [ - { - id: '1', - title: 'JavaScript Fundamentals', - category: 'Programming', - startTime: new Date('2024-01-15T10:00:00'), - endTime: new Date('2024-01-15T12:00:00'), - totalQuestions: 50, - totalMarks: 100, - cutoffMarks: 40, - totalCandidates: 25, - status: 'ended', - createdAt: new Date('2024-01-10T09:00:00'), - updatedAt: new Date('2024-01-15T12:00:00') - }, - { - id: '2', - title: 'Angular Development', - category: 'Frontend', - startTime: new Date('2024-01-20T14:00:00'), - endTime: new Date('2024-01-20T16:00:00'), - totalQuestions: 40, - totalMarks: 80, - cutoffMarks: 32, - totalCandidates: 30, - status: 'upcoming', - createdAt: new Date('2024-01-12T10:00:00'), - updatedAt: new Date('2024-01-12T10:00:00') - }, - { - id: '3', - title: 'Database Management', - category: 'Backend', - startTime: new Date('2024-01-18T09:00:00'), - endTime: new Date('2024-01-18T11:00:00'), - totalQuestions: 60, - totalMarks: 120, - cutoffMarks: 48, - totalCandidates: 20, - status: 'ongoing', - createdAt: new Date('2024-01-08T14:00:00'), - updatedAt: new Date('2024-01-18T09:00:00') - } - ]; - } } diff --git a/otpcasestudy-angular-app/src/app/services/question.service.ts b/otpcasestudy-angular-app/src/app/services/question.service.ts index 8eb6167..c07d5a1 100644 --- a/otpcasestudy-angular-app/src/app/services/question.service.ts +++ b/otpcasestudy-angular-app/src/app/services/question.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; -import { Observable } from "rxjs"; +import { map, Observable } from "rxjs"; import { environment } from "../../environments/environment"; export interface Question { @@ -20,20 +20,28 @@ export interface Question { }) export class QuestionService { - private apiUrl = `${environment.apiBaseUrl}/questions`; + private apiUrl = `${environment.apiBaseUrl}/admin/question`; constructor(private http: HttpClient) { } uploadQuestions(file: File): Observable { const formData = new FormData(); formData.append('file', file); - return this.http.post(`${this.apiUrl}/upload`, formData); + return this.http.post(`${this.apiUrl}/upload-file`, formData); } - getQuestionsByCategory(Category: string): Observable { - return this.http.get(`${this.apiUrl}/category/${Category}`); + getAllCategories(): Observable { + return this.http.get(`${this.apiUrl}/category`).pipe( + map(res => res?.data || []) + ) } + getQuestionCountByCategory(categoryId: number, marks: number): Observable { + return this.http.get(`${this.apiUrl}/category/${categoryId}/count?marks=${marks}`) + .pipe(map(res => res.data || 0)) + } + + getAllQuestions(): Observable { return this.http.get(this.apiUrl); } @@ -41,109 +49,4 @@ export class QuestionService { deleteQuestion(id: string): Observable { return this.http.delete(`${this.apiUrl}/${id}`); } - - getMockQuestions(): Question[] { - return [ - { - id: '1', - question: 'What is JavaScript?', - optionA: 'A programming language', - optionB: 'A markup language', - optionC: 'A styling language', - optionD: 'A database', - correctAnswer: 'A', - category: 'Programming', - marks: 2 - }, - { - id: '2', - question: 'Which framework is used for building web applications?', - optionA: 'React', - optionB: 'Angular', - optionC: 'Vue', - optionD: 'All of the above', - correctAnswer: 'D', - category: 'Frontend', - marks: 2 - } - ] - } - - getMockCategoriesWithMarks(): { categoryName: string; marksList: number[] }[] { - return [ - { - categoryName: 'math', - marksList: [1, 2, 3] - }, - { - categoryName: 'science', - marksList: [1, 2, 3] - }, - { - categoryName: 'math', - marksList: [1, 2, 3] - } - ]; - } - - getMockQuestionsForCategoryAndMarks(): { - [key: string]: number[] - } { - return { - 'math-1': [3, 5, 7], - 'math-2': [2, 4, 6], - 'math-3': [1, 3], - - 'science-1': [4, 6], - 'science-2': [2, 5], - 'science-3': [3, 6], - - 'english-1': [5, 10], - 'english-2': [3, 6], - 'english-3': [2, 4] - }; - } - - getAvailableQuestionCounts(category: string, marks: number): number[] { - const mockData: Record> = { - math: { - 1: [5, 10, 15], - 2: [10, 20], - 3: [15, 30] - }, - science: { - 1: [5, 10], - 2: [10, 25], - 3: [20, 40] - }, - english: { - 1: [5, 10, 20], - 2: [10, 20, 30], - 3: [15, 25] - } - }; - - return mockData[category]?.[marks] || []; - } - - getMockQuestionsCombination() { - return [ - { - categoryName: 'Math', - marks: 1, - availableQuestions: [5, 10, 15] - }, - { - categoryName: 'English', - marks: 2, - availableQuestions: [10, 20] - }, - { - categoryName: 'Science', - marks: 1, - availableQuestions: [5, 8] - } - ]; - } - } \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/environments/environment.prod.ts b/otpcasestudy-angular-app/src/environments/environment.prod.ts index bd9f169..716b09d 100644 --- a/otpcasestudy-angular-app/src/environments/environment.prod.ts +++ b/otpcasestudy-angular-app/src/environments/environment.prod.ts @@ -1,6 +1,6 @@ export const environment = { production: true, - apiBaseUrl: 'https://api.otp-portal.com/api', + apiBaseUrl: 'http://localhost:8081/api/v1', appName: 'Online Test Portal', version: '1.0.0' } \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/environments/environment.ts b/otpcasestudy-angular-app/src/environments/environment.ts index 8705220..0e7a106 100644 --- a/otpcasestudy-angular-app/src/environments/environment.ts +++ b/otpcasestudy-angular-app/src/environments/environment.ts @@ -1,6 +1,6 @@ export const environment = { production: false, - apiBaseUrl: 'https://api.otp-portal.com/api', + apiBaseUrl: 'http://localhost:8081/api/v1', appName: 'Online Test Portal', version: '1.0.0' } \ No newline at end of file