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!
+
+
+
+
school
+
NextStep Admin Portal
+
Online Test Platform Management
+
+
+
+
+
+
+ Admin Login
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
school
+
NextStep Admin Portal
+
Online Test Platform Management
+
+
+
+
+
+
+ Admin Login
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
school
+
NextStep Candidate Portal
+
Online Test Platform
+
+
+
+
+
+
+ Candidate Login
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ school
+ 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 @@
+
+ school
+ 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 @@
+
+
+
+ school
+ NextStep
+
+
Candidate Registration
+
+
+
+
\ 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 @@
+
+
+
+ school
+ NextStep
+
+
+
+
Exam starts in:
+
+ timer
+ {{
+ countdownTimeLeft | duration
+ }}
+
+
+
+
+ Instructions:
+
+ - This exam consists of multiple-choice questions.
+ - Ensure you have a stable internet connection.
+ - The exam will start automatically when the timer reaches zero.
+ - Do not close or refresh this window.
+ - All the best!
+
+
+
+
+
+
+
+
+ school
+ NextStep
+
+
+
+ timer
+ {{ timeLeft | duration }}
+
+
+
{{ exam.examName }}
+
+
+
{{ cat.name }}
+
+ {{
+ cat.sections &&
+ cat.sections.length > 0 &&
+ cat.sections[0].questions
+ ? cat.sections[0].questions.length
+ : 0
+ }}
+ Questions
+
+
+
+
+
+
+ Please ensure you read all questions carefully before submitting your
+ exam.
+
+
+
+ Not Seen
+ Seen, Not Answered
+
+
+ Answered
+ Marked for Review
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Submit Category?
+
+ Once you submit this category, you cannot return to it. Are you sure you
+ want to proceed?
+
+
+
+
+
+
+
+
+
+
+
Submit Exam?
+
+ Are you sure you want to submit the exam? You will not be able to make any
+ more changes.
+
+
+
+
+
+
+
+
+
+
+
+
15 Minutes Left
+
You have 15 minutes remaining. Please manage your time carefully.
+
+
+
+
+
+
+
+
+
5 Minutes Left
+
+ You have 5 minutes remaining. Please finalize your answers and submit
+ soon.
+
+
+
+
+
+
+
+
+
+
+ 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