diff --git a/otpcasestudy-angular-app/src/app/app.routes.ts b/otpcasestudy-angular-app/src/app/app.routes.ts index 6662a08..0c956f4 100644 --- a/otpcasestudy-angular-app/src/app/app.routes.ts +++ b/otpcasestudy-angular-app/src/app/app.routes.ts @@ -14,6 +14,7 @@ import { RegistrationComponent } from './components/candidate/registration/regis 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'; +import { StayOnPageGuard } from './guards/stay-on-page.guard'; export const routes: Routes = [ { @@ -35,10 +36,14 @@ export const routes: Routes = [ loadComponent: () => import('./components/admin/login/login.component').then(m => m.LoginComponent) }, { path: 'landing', component: LandingComponent }, - { path: 'admin-login', component: AdminLoginComponent }, + { path: 'admin-login', component: AdminLoginComponent }, { path: 'verify-email', component: EmailVerificationComponent }, { path: 'registration', component: RegistrationComponent }, { path: 'exam', component: ExamWindowComponent, canDeactivate: [ExamExitGuard] }, + { + path: 'exam-result', + loadComponent: () => import('./components/exam-window/result-window/result-window.component').then(m => m.ResultWindowComponent), + }, { path: 'candidate-login', component: CandidateLoginComponent }, { path: '', pathMatch: 'full', redirectTo: 'admin/dashboard' }, { path: '**', redirectTo: 'landing' } diff --git a/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.html b/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.html index 84b5f2f..deed680 100644 --- a/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.html +++ b/otpcasestudy-angular-app/src/app/components/admin/admin-layout/admin-layout.component.html @@ -55,6 +55,18 @@ + +
+ Candidate Registration + + +
@@ -206,10 +209,12 @@
Exam Sections
- - \ 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 index 2ad3746..ff03de9 100644 --- a/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.scss +++ b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.scss @@ -1,16 +1,23 @@ +html { + scrollbar-width: none; +} + +body { + overflow: hidden; +} + .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; + max-width: 800px; + padding: 20px 32px; border-radius: 16px; box-shadow: 0 8px 32px rgba(25, 118, 210, 0.12); background: #fff; @@ -62,7 +69,19 @@ gap: 20px; } +.form-row { + display: flex; + gap: 16px; + width: 100%; + + @media (max-width: 768px) { + flex-direction: column; + gap: 20px; + } +} + .form-field { + flex: 1; width: 100%; } @@ -102,8 +121,9 @@ } .form-actions button.go-home-btn { - background: #7c4dff !important; - color: #fff !important; + background: #fff !important; + color: #3b1877 !important; + box-shadow: none; } .form-actions button.go-home-btn:hover, .form-actions button.go-home-btn:focus { @@ -158,3 +178,43 @@ gap: 16px; } } + +.status-message { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + margin: 16px 0; + border-radius: 8px; + font-weight: 500; + + &.checking { + background-color: #e3f2fd; + color: #1976d2; + border: 1px solid #bbdefb; + + mat-icon { + color: #1976d2; + animation: spin 2s linear infinite; + } + } + + &.disabled { + background-color: #ffebee; + color: #d32f2f; + border: 1px solid #ffcdd2; + + mat-icon { + color: #d32f2f; + } + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} 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 index 06f3d3f..7815c31 100644 --- a/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.ts +++ b/otpcasestudy-angular-app/src/app/components/candidate/registration/registration.component.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { CandidateRegistrationService } from '../../../services/candidate-registration.service'; +import { ExamService } from '../../../services/exam.service'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -27,26 +28,54 @@ import { Router } from '@angular/router'; templateUrl: './registration.component.html', styleUrls: ['./registration.component.scss'] }) -export class RegistrationComponent { +export class RegistrationComponent implements OnInit { + phoneReg = /^(?:\+91\s?)?(?!9999999999)(?!9000000000)(?!0000000000)[6-9][0-9]{9}$/; registrationForm = this.fb.group({ - name: ['', Validators.required], + name: ['', [Validators.required, Validators.pattern('^[a-zA-Z]+(?: [a-zA-Z]+)*$')]], email: ['', [Validators.required, Validators.email]], - mobileNumber: ['', [Validators.required, Validators.pattern('^[0-9]{10,15}$')]], - collegeName: ['', Validators.required], - degreeName: ['', Validators.required], - city: ['', Validators.required] + mobileNumber: ['', [Validators.required, Validators.pattern(this.phoneReg)]], + collegeName: ['', [Validators.required, Validators.pattern('^[a-zA-Z]+(?: [a-zA-Z]+)*$')]], + degreeName: ['', [Validators.required, Validators.pattern('^[a-zA-Z.]+(?: [a-zA-Z.]+)*$')]], + city: ['', [Validators.required, Validators.pattern('^[a-zA-Z]+(?: [a-zA-Z]+)*$')]] }); loading = false; + registrationEnabled = true; + checkingStatus = true; constructor( private fb: FormBuilder, private candidateRegService: CandidateRegistrationService, + private examService: ExamService, private snackBar: MatSnackBar, private router: Router ) { } + ngOnInit(): void { + this.checkRegistrationStatus(); + + } + + checkRegistrationStatus(): void { + this.checkingStatus = true; + this.examService.getRegistrationStatus().subscribe({ + next: (status) => { + this.registrationEnabled = status; + this.checkingStatus = false; + if (!status) { + this.registrationForm.disable(); + } + }, + error: (err) => { + console.error('Failed to check registration status:', err); + this.checkingStatus = false; + // Default to enabled if we can't check status + this.registrationEnabled = true; + } + }); + } + register() { - if (this.registrationForm.invalid) return; + if (this.registrationForm.invalid || !this.registrationEnabled) return; this.loading = true; this.candidateRegService.register(this.registrationForm.value).subscribe({ next: () => { @@ -61,7 +90,7 @@ export class RegistrationComponent { const errorMessage = err?.error?.message || 'Registration failed.'; console.log(errorMessage); - if (errorMessage.includes('already exists')) { + if (errorMessage.includes('Already Registered')) { const snackBarRef = this.snackBar.open('This email is already registered but not verified.', 'Resend Verification', { duration: 10000 }); @@ -69,7 +98,7 @@ export class RegistrationComponent { this.resendVerification(); }); } else { - this.snackBar.open(errorMessage, '', { duration: 5000 }); + this.snackBar.open(errorMessage, '', { duration: 10000 }); } } }); diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.html b/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.html new file mode 100644 index 0000000..b845fdf --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.html @@ -0,0 +1 @@ +

exam-submitted works!

diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.scss b/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.ts b/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.ts new file mode 100644 index 0000000..7d82506 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/exam-submitted/exam-submitted.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-exam-submitted', + standalone: true, + imports: [], + templateUrl: './exam-submitted.component.html', + styleUrl: './exam-submitted.component.scss' +}) +export class ExamSubmittedComponent { + +} 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 index e5238c4..c106f7a 100644 --- 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 @@ -5,34 +5,42 @@ NextStep
-
- Welcome, {{ exam.candidateName }} -
-
- Exam: {{ exam.examName }} -
+
Welcome to the Exam
-
Exam starts in:
+
+ Exam starts in: + Ready to start: +
- timer - {{ - countdownTimeLeft | duration - }} -

Instructions:

- +
@@ -43,34 +51,32 @@

Instructions:

NextStep
-
+
timer {{ timeLeft | duration }}
- +
-
{{ cat.name }}
+
+ {{ cat.categoryName }} +
- {{ - cat.sections && - cat.sections.length > 0 && - cat.sections[0].questions - ? cat.sections[0].questions.length - : 0 - }} - Questions + {{ cat.questionList.length }} Questions
+ Instructions: > Seen, Not AnsweredYet To Be Answerd
@@ -118,7 +124,7 @@

Instructions:

- {{ currentCategory.name }} + {{ currentCategory.categoryName }}
Instructions:
- {{ currentQuestion.text }} + {{ currentQuestion.questionText }}
- + - {{ opt }} + {{ opt.optionText }}
@@ -167,7 +170,9 @@

Instructions:

Clear Answer
- -
-
- -

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 index 07b0242..5aebd9f 100644 --- 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 @@ -579,7 +579,7 @@ mat-card { margin: 0 auto; padding: 32px 24px 24px 24px; border-radius: 18px; - box-shadow: 0 8px 32px rgba(60, 24, 119, 0.10); + box-shadow: 0 8px 32px rgba(60, 24, 119, 0.1); background: #fff; } @@ -639,22 +639,66 @@ mat-card { } .waiting-timer { display: flex; + justify-content: center; align-items: center; - gap: 10px; - background: #ede7f6; + margin-top: 20px; +} + +.waiting-timer-label { + text-align: center; + font-size: 18px; + font-weight: 500; + color: #667eea; + margin-bottom: 10px; +} + +.countdown-display { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px; + background: #3b1877; 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); + color: white; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + + .countdown-icon { + font-size: 24px; + color: white; + } + + .countdown-time { + font-size: 24px; + font-weight: 700; + font-family: "Courier New", monospace; + letter-spacing: 2px; + } +} + +.start-exam-button { + padding: 12px 32px; + font-size: 16px; + font-weight: 600; + border-radius: 25px; + background: linear-gradient(135deg, #4caf50 0%, #45a049 100%) !important; + box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4); + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(76, 175, 80, 0.6); + } + + mat-icon { + margin-right: 8px; + } } .timer-icon { font-size: 2.2rem; color: #3b1877; } .waiting-timer-value { - font-family: 'Roboto Mono', monospace; + font-family: "Roboto Mono", monospace; font-size: 2.1rem; color: #3b1877; font-weight: 700; @@ -673,6 +717,158 @@ mat-card { margin-bottom: 0; } +// Enhanced Result Display Styles +.result-card { + max-width: 600px !important; + width: 90vw; + border-radius: 20px !important; + box-shadow: 0 12px 40px rgba(59, 24, 119, 0.15) !important; + overflow: hidden; +} + +.result-header { + background: linear-gradient(135deg, #3b1877 0%, #5e35b1 100%); + color: white !important; + padding: 24px !important; + margin: -24px -24px 24px -24px !important; + text-align: center; + + .mat-card-title { + color: white !important; + font-size: 1.5rem !important; + font-weight: 700 !important; + margin-bottom: 8px !important; + } + + .mat-card-subtitle { + color: rgba(255, 255, 255, 0.9) !important; + font-size: 1rem !important; + } +} + +.result-avatar { + background: rgba(255, 255, 255, 0.2) !important; + color: white !important; + width: 60px !important; + height: 60px !important; + margin: 0 auto 16px auto !important; + + mat-icon { + font-size: 32px !important; + width: 32px !important; + height: 32px !important; + } +} + +.result-content { + padding: 0 8px !important; +} + +.result-info-grid { + display: grid; + grid-template-columns: 1fr; + gap: 20px; + margin-bottom: 24px; +} + +.result-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: #f8fafc; + border-radius: 12px; + border-left: 4px solid #3b1877; + transition: all 0.3s ease; + + &:hover { + background: #f1f5f9; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 24, 119, 0.1); + } + + &.status-item { + border-left-color: #4caf50; + + &.warning { + border-left-color: #ff9800; + } + } +} + +.result-icon { + color: #3b1877 !important; + font-size: 24px !important; + width: 24px !important; + height: 24px !important; + flex-shrink: 0; + + &.success-icon { + color: #4caf50 !important; + } + + &.warning-icon { + color: #ff9800 !important; + } +} + +.result-details { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.result-label { + font-size: 0.9rem; + color: #666; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.result-value { + font-size: 1.1rem; + color: #333; + font-weight: 600; + + &.success-text { + color: #4caf50; + font-weight: 700; + } + + &.warning-text { + color: #ff9800; + font-weight: 700; + } +} + +.result-actions { + padding: 0 !important; + margin-top: 24px !important; + + .finish-btn { + width: 100%; + padding: 14px 24px; + font-size: 1.1rem; + font-weight: 600; + border-radius: 12px; + background: linear-gradient(135deg, #3b1877 0%, #5e35b1 100%) !important; + color: white !important; + box-shadow: 0 4px 16px rgba(59, 24, 119, 0.3); + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 24, 119, 0.4); + } + + mat-icon { + margin-right: 8px; + } + } +} + @media (max-width: 900px) { .exam-layout { flex-direction: column; @@ -699,4 +895,18 @@ mat-card { padding: 12px 4px; max-height: 50vh; } + + .result-card { + width: 95vw !important; + margin: 16px !important; + } + + .result-info-grid { + gap: 16px; + } + + .result-item { + padding: 12px; + gap: 12px; + } } 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 index 636a0bf..8fd0a55 100644 --- 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 @@ -1,6 +1,11 @@ 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 { + ExamDetails, + ExamCategory, + ExamQuestion, + ExamOption, +} from '../../models/exam-session.model'; import { MatSnackBar } from '@angular/material/snack-bar'; import { interval, Subscription } from 'rxjs'; import { CommonModule } from '@angular/common'; @@ -26,20 +31,26 @@ import { Router } from '@angular/router'; FormsModule, QuestionNavigatorComponent, DurationPipe, - MatDividerModule + MatDividerModule, ], templateUrl: './exam-window.component.html', - styleUrls: ['./exam-window.component.scss'] + styleUrls: ['./exam-window.component.scss'], }) export class ExamWindowComponent implements OnInit, OnDestroy { exam: ExamDetails | null = null; currentCategory: ExamCategory | null = null; - currentSection: ExamSection | null = null; + currentSection: { questions: ExamQuestion[] } | 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(); + 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 @@ -60,6 +71,7 @@ export class ExamWindowComponent implements OnInit, OnDestroy { show5MinWarning = false; timerBarRed = false; startExamEnabled = false; + a = ''; constructor( private examService: ExamSessionService, @@ -68,36 +80,64 @@ export class ExamWindowComponent implements OnInit, OnDestroy { ) { } ngOnInit() { - // Block inspect element and other security measures + // Enable all security measures for exam integrity 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); + document.addEventListener('fullscreenchange', this.handleFullScreenChange); window.history.pushState(null, '', window.location.href); window.addEventListener('popstate', this.handlePopState); + // Disable text selection and drag + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + (document.body.style as any).mozUserSelect = 'none'; + (document.body.style as any).msUserSelect = 'none'; + + // Disable drag and drop + document.addEventListener('dragstart', this.preventDragDrop); + document.addEventListener('drop', this.preventDragDrop); + // 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'); + 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) { + + console.log(examData.answers); + + if (examData && examData.exam && examData.exam.questionCategoryList) { this.exam = examData.exam; this.answers = examData.answers || {}; - this.timeLeft = examData.timeLeft || 0; + console.log(this.answers); + + this.timeLeft = Math.floor(((new Date(sessionData.endTime).getTime() - new Date().getTime()) - 19800000) / 1000); + console.log(new Date(sessionData.endTime).getTime()); + console.log((new Date(sessionData.endTime).getTime() - new Date().getTime())); + this.examStarted = examData.examStarted || false; this.beforeStart = !this.examStarted; if (this.examStarted) { this.startTimer(); + if (this.exam?.questionCategoryList?.[0]) { + this.selectCategory(this.exam.questionCategoryList[0]); // Select first category + } + } else { this.startCountdown(); + if (this.exam?.questionCategoryList?.[0]) { + this.selectCategory(this.exam.questionCategoryList[0]); // Select first category + } } return; } @@ -108,53 +148,47 @@ export class ExamWindowComponent implements OnInit, OnDestroy { next: (response: any) => { this.exam = response.data; if (!this.exam) { - this.snackBar.open('Could not retrieve exam details.', '', { duration: 3000 }); + this.snackBar.open('Could not retrieve exam details.', '', { + duration: 3000, + }); this.router.navigate(['/candidate-login']); return; } + // Add examId to the main session data for later use 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.countdownTimeLeft = Math.floor(((start - now) / 1000) - 19800000) this.beforeStart = true; this.examStarted = false; - this.saveExamStateToSession(); + this.saveExamStateToSession(); // Save the initial state this.startCountdown(); }, error: (err: any) => { - this.snackBar.open(err?.error?.message || 'Failed to load exam details.', '', { duration: 5000 }); - } + 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 + examStarted: this.examStarted, }); } @@ -164,27 +198,42 @@ export class ExamWindowComponent implements OnInit, OnDestroy { const examKey = sessionData.examKey; this.examService.fetchExamDataFromBackend(examKey).subscribe({ next: (response: any) => { - this.exam = response.data; + this.exam = response.data; // Backend nests the response in a 'data' object if (!this.exam) { this.snackBar.open('Could not start exam.', '', { duration: 3000 }); return; } + // Add examId to the main session data 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; + const now = new Date().getTime(); + console.log(now); + + const end = new Date(sessionData.endTime).getTime() - 19800000; + console.log(end); + + this.timeLeft = Math.floor(((end - now) / 1000)); + console.log(this.timeLeft); + this.examStarted = true; this.beforeStart = false; this.saveExamStateToSession(); - this.selectCategory(0); // Select first category + if (this.exam?.questionCategoryList?.[0]) { + this.selectCategory(this.exam.questionCategoryList[0]); // Select first category + } this.startTimer(); }, error: (err: any) => { - this.snackBar.open(err?.error?.message || 'Failed to fetch exam data.', '', { duration: 5000 }); - } + this.snackBar.open( + err?.error?.message || 'Failed to fetch exam data.', + '', + { duration: 5000 } + ); + }, }); } @@ -193,84 +242,152 @@ export class ExamWindowComponent implements OnInit, OnDestroy { this.timeLeft--; // Save timeLeft to sessionStorage const examData = this.examService.getExamDataFromSession(); - examData.timeLeft = this.timeLeft; - this.examService.saveExamDataToSession(examData); - if (this.timeLeft === 15 * 60) { + if (examData) { + examData.timeLeft = this.timeLeft; + this.examService.saveExamDataToSession(examData); + } + if (this.timeLeft <= 0) { + this.submitExam(true); + } + if (this.timeLeft === 900 && !this.show15MinWarning) { this.show15MinWarning = true; + this.snackBar.open('15 minutes remaining!', '', { duration: 5000 }); } - if (this.timeLeft === 5 * 60) { + if (this.timeLeft === 300 && !this.show5MinWarning) { this.show5MinWarning = true; + this.snackBar.open('5 minutes remaining!', '', { duration: 5000 }); + } + if (this.timeLeft <= 300) { this.timerBarRed = true; } - if (this.timeLeft <= 0) { - this.submitExam(); + }); + } + + startCountdown() { + const sessionData = this.examService.getExamSessionData(); + if (!sessionData || !sessionData.startTime) { + console.error('No start time found in session data'); + return; + } + + console.log(sessionData.startTime); + + // Calculate time remaining until exam start time + const startTime = new Date(sessionData.startTime.replace('Z', '')).getTime(); + const now = new Date().getTime(); + this.countdownTimeLeft = Math.floor((startTime - now) / 1000); + + // If start time has already passed, enable the start button immediately + if (this.countdownTimeLeft <= 0) { + this.countdownTimeLeft = 0; + this.startExamEnabled = true; + return; + } + + // Start countdown timer + this.countdownSub = interval(1000).subscribe(() => { + this.countdownTimeLeft--; + + if (this.countdownTimeLeft <= 0) { + this.countdownTimeLeft = 0; + this.startExamEnabled = true; + this.countdownSub?.unsubscribe(); + this.snackBar.open('Exam is now available to start!', '', { duration: 3000 }); + } }); } - 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); + // Helper method to format countdown time display + formatCountdownTime(): string { + if (this.countdownTimeLeft <= 0) return '00:00:00'; + + const hours = Math.floor((this.countdownTimeLeft / 3600)); + const minutes = Math.floor(((this.countdownTimeLeft % 3600) / 60)); + const seconds = this.countdownTimeLeft % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '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; - } + selectCategory(category: ExamCategory) { + this.currentCategory = category; + this.currentQuestionIndex = 0; // Reset index when category changes + + // Update the current category index for UI highlighting + if (this.exam?.questionCategoryList) { + this.currentCategoryIndex = this.exam.questionCategoryList.findIndex(cat => cat.id === category.id); } } get currentQuestion(): ExamQuestion | null { - if (!this.currentSection) return null; - return this.currentSection.questions[this.currentQuestionIndex] || null; + if (!this.currentCategory) return null; + return this.currentCategory.questionList[this.currentQuestionIndex] || null; } get totalQuestions(): number { - return this.currentSection ? this.currentSection.questions.length : 0; + return this.currentCategory ? this.currentCategory.questionList.length : 0; } saveAnswer(answer: string | null) { const q = this.currentQuestion; - if (!q || !this.answers) return; + if (!q) return; this.answers[q.id] = { answer, markedForReview: false, seen: true }; - this.examService.saveAnswerToSession(q.id, answer, false); + this.examService.saveAnswerToSession(q.id, answer); } 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); + if (!q) return; + + // Initialize answer record if it doesn't exist + if (!this.answers[q.id]) { + this.answers[q.id] = { + answer: null, + markedForReview: false, + seen: true + }; + } + + // Toggle the markedForReview status + this.answers[q.id] = { + answer: this.answers[q.id].answer, + markedForReview: !this.answers[q.id].markedForReview, + seen: true, + }; + + // Save the current answer to session storage + this.examService.saveAnswerToSession(q.id, this.answers[q.id].answer); } clearAnswer() { const q = this.currentQuestion; if (!q) return; - this.answers[q.id] = { answer: null, markedForReview: false }; + + // Clear the visual selection in the radio button + this.a = ''; + + // Update the answers object + this.answers[q.id] = { + answer: null, + markedForReview: this.answers[q.id]?.markedForReview || false, + seen: true + }; + + // Save the cleared answer to session storage + this.examService.saveAnswerToSession(q.id, null); } goToQuestion(index: number) { this.currentQuestionIndex = index; - const q = this.currentSection?.questions[index]; + const q = this.currentCategory?.questionList[index]; if (q) { this.seenQuestions.add(q.id); if (!this.answers[q.id]) { - this.answers[q.id] = { answer: null, markedForReview: false, seen: true }; + this.answers[q.id] = { + answer: null, + markedForReview: false, + seen: true, + }; } else { this.answers[q.id].seen = true; } @@ -278,14 +395,17 @@ export class ExamWindowComponent implements OnInit, OnDestroy { } nextQuestion() { - if (this.currentSection && this.currentQuestionIndex < this.currentSection.questions.length - 1) { - this.goToQuestion(this.currentQuestionIndex + 1); + if ( + this.currentCategory && + this.currentQuestionIndex < this.currentCategory.questionList.length - 1 + ) { + this.currentQuestionIndex++; } } prevQuestion() { - if (this.currentSection && this.currentQuestionIndex > 0) { - this.goToQuestion(this.currentQuestionIndex - 1); + if (this.currentCategory && this.currentQuestionIndex > 0) { + this.currentQuestionIndex--; } } @@ -302,53 +422,54 @@ export class ExamWindowComponent implements OnInit, OnDestroy { } this.showSubmitExamModal = true; } + closeSubmitExamModal() { this.showSubmitExamModal = false; } - submitExam(autoSubmit = false) { if (this.submitting) return; this.submitting = true; + + // Show warning for auto-submit due to tab switching + if (autoSubmit) { + this.snackBar.open('Exam auto-submitted due to security violation!', '', { + duration: 5000, + panelClass: 'warn-snackbar' + }); + } + this.examService.submitExamPayloadToBackend().subscribe({ - next: () => { - this.snackBar.open('Exam submitted successfully.', '', { duration: 3000 }); + next: (response: any) => { + 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; + + // Navigate to result window after successful submission + // The result data is already stored in session storage by the service + setTimeout(() => { + this.router.navigate(['/exam-result']); + }); }, error: (err: any) => { - this.snackBar.open(err?.error?.message || 'Failed to submit exam.', '', { duration: 3000 }); + 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 }); + this.snackBar.open('Exam ID not found in session.', '', { + duration: 3000, + }); return; } this.examService.getResult(sessionData.examId).subscribe({ @@ -356,51 +477,66 @@ export class ExamWindowComponent implements OnInit, OnDestroy { this.result = result; }, error: (err) => { - this.snackBar.open(err?.error?.message || 'Failed to load result.', '', { duration: 3000 }); - } + this.snackBar.open( + err?.error?.message || 'Failed to load result.', + '', + { duration: 3000 } + ); + }, }); } submitCategory() { - // Lock the current category and save answers to sessionStorage + this.closeCategoryModal() 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; - } + const currentCatId = + (this.currentCategory as any).id || + (this.currentCategory as any).categoryId; + if (this.exam && this.exam.questionCategoryList) { } - // 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 + 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); + if (this.exam && this.exam.questionCategoryList) { + const nextIndex = + this.exam.questionCategoryList.findIndex( + (c: any) => (c.id || c.categoryId) === currentCatId + ) + 1; + if (nextIndex < this.exam.questionCategoryList.length) { + this.selectCategory(this.exam.questionCategoryList[nextIndex]); } } } blockContextMenu = (e: Event) => { e.preventDefault(); + this.snackBar.open('Right-click is disabled during the exam.', '', { duration: 2000 }); }; blockInspectKeys = (e: KeyboardEvent) => { + // Block developer tools and inspect element shortcuts if ( e.key === 'F12' || (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'J' || e.key === 'C')) || - (e.ctrlKey && e.key === 'U') + (e.ctrlKey && e.key === 'U') || + (e.ctrlKey && e.shiftKey && e.key === 'K') || + (e.ctrlKey && e.key === 'S') || + (e.ctrlKey && e.key === 'A') || + (e.ctrlKey && e.key === 'P') || + (e.key === 'F5') || + (e.ctrlKey && e.key === 'R') ) { e.preventDefault(); - }; + this.snackBar.open('This action is not allowed during the exam.', '', { duration: 2000 }); + } + }; + + preventDragDrop = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); }; listerForUserGesture() { @@ -409,7 +545,7 @@ export class ExamWindowComponent implements OnInit, OnDestroy { window.removeEventListener('click', triggerFullScreen); window.removeEventListener('keydown', triggerFullScreen); window.removeEventListener('mousemove', triggerFullScreen); - } + }; window.addEventListener('click', triggerFullScreen); window.addEventListener('keydown', triggerFullScreen); @@ -439,12 +575,9 @@ export class ExamWindowComponent implements OnInit, OnDestroy { handleFullScreenChange = () => { if (!document.fullscreenElement && this.examStarted && !this.examEnded) { - this.tabSwitchCount++; - this.showTabSwitchWarning(); this.enterFullScreen(); } - } - + }; handleTabSwitch = () => { this.tabSwitchCount++; @@ -466,7 +599,11 @@ export class ExamWindowComponent implements OnInit, OnDestroy { { 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.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); } } @@ -479,7 +616,8 @@ export class ExamWindowComponent implements OnInit, OnDestroy { beforeUnloadHandler = (event: BeforeUnloadEvent) => { if (this.examStarted) { event.preventDefault(); - event.returnValue = 'Are you sure you want to leave the exam? Your progress may be lost.'; + event.returnValue = + 'Are you sure you want to leave the exam? Your progress may be lost.'; return event.returnValue; } return undefined; @@ -489,13 +627,21 @@ export class ExamWindowComponent implements OnInit, OnDestroy { 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); + 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; } + close15MinWarning() { + this.show15MinWarning = false; + } + close5MinWarning() { + this.show5MinWarning = false; + } confirmSubmitExam() { this.closeSubmitExamModal(); @@ -506,10 +652,13 @@ export class ExamWindowComponent implements OnInit, OnDestroy { window.removeEventListener('contextmenu', this.blockContextMenu); window.removeEventListener('keydown', this.blockInspectKeys); window.removeEventListener('blur', this.handleTabSwitch); - document.removeEventListener('visibilitychange', this.handleVisibilityChange); + 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.ts b/otpcasestudy-angular-app/src/app/components/exam-window/question-navigator/question-navigator.component.ts index 3ee20e5..457f6ca 100644 --- 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 @@ -14,15 +14,24 @@ 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(); + @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'; + + const questionAnswer = this.answers[q.id]; + + // Check if answered + if (questionAnswer?.answer) return 'answered'; + + // Check if marked for review + if (questionAnswer?.markedForReview) return 'review'; + + // Check if seen but not answered 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/components/exam-window/result-window/result-window.component.html b/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.html new file mode 100644 index 0000000..e9891c1 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.html @@ -0,0 +1,128 @@ +
+ +
+ +
+ hourglass_empty +

Loading Results...

+

Please wait while we process your exam results.

+
+
+
+ + +
+ +
+

Exam Result

+
+ {{ result.candidateName }}Here is your exam result +
+
+ + +
+
+ grade +
+ Marks Obtained + {{ result.marksObtained }} +
+
+ +
+ assignment +
+ Total Marks + {{ result.totalMarks }} +
+
+ +
+ percent +
+ Percentage + {{ getPercentage() }} +
+
+ +
+ + {{ isQualified() ? "check_circle" : "cancel" }} + +
+ Status + + {{ getStatusText() }} + +
+
+
+ + +
+

+ category + Category-wise Performance +

+
+
+
+
+ {{ category.categoryName }} + {{ + category.obtainedMarks + }} +
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ + +
+ +
+ error_outline +

No Results Found

+

+ We couldn't find your exam results. You will be redirected to the home + page. +

+ +
+
+
+
diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.scss b/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.scss new file mode 100644 index 0000000..2acf735 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.scss @@ -0,0 +1,436 @@ +.result-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: #f5f5f5; +} + +// Loading State Styles +.loading-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.loading-card { + max-width: 400px; + width: 100%; + border-radius: 20px !important; + box-shadow: 0 8px 32px rgba(59, 24, 119, 0.15) !important; + text-align: center; +} + +.loading-content { + padding: 40px 20px; + + .loading-icon { + font-size: 48px !important; + width: 48px !important; + height: 48px !important; + color: #3b1877; + margin-bottom: 16px; + animation: spin 2s linear infinite; + } + + h3 { + color: #3b1877; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 8px; + } + + p { + color: #666; + font-size: 1rem; + margin: 0; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +// No Result State Styles +.no-result-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.no-result-card { + max-width: 400px; + width: 100%; + border-radius: 20px !important; + box-shadow: 0 8px 32px rgba(59, 24, 119, 0.15) !important; + text-align: center; +} + +.no-result-content { + padding: 40px 20px; + + .no-result-icon { + font-size: 48px !important; + width: 48px !important; + height: 48px !important; + color: #ff9800; + margin-bottom: 16px; + } + + h3 { + color: #3b1877; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 8px; + } + + p { + color: #666; + font-size: 1rem; + margin-bottom: 24px; + } + + button { + padding: 12px 24px; + font-size: 1.1rem; + font-weight: 600; + border-radius: 12px; + + mat-icon { + margin-right: 8px; + } + } +} + +// Result Card Styles +mat-card { + padding: 20px; + padding-right: 50px; + text-align: center; + max-width: 600px; + width: 90vw; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(59, 24, 119, 0.1); +} + +.center { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding-right: 40px; +} + +.result-logo-bar { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +} + +.result-logo { + font-size: 72px; + color: #3b1877; + margin-bottom: 10px; + width: 72px; + height: 72px; + line-height: 72px; + display: flex; + align-items: center; + justify-content: center; +} + +.result-portal-name { + font-size: 2.3rem; + font-weight: 700; + color: #3b1877; + letter-spacing: 1px; + margin-bottom: 4px; +} + +.result-header { + text-align: center; + margin-bottom: 30px; + + h2 { + color: #3b1877; + font-size: 1.8rem; + font-weight: 700; + margin-bottom: 8px; + } + + .result-subtitle { + color: #666; + font-size: 1.1rem; + font-weight: 500; + } +} + +.result-content { + width: 100%; +} + +.result-info-row { + display: flex; + flex-direction: row; + gap: 12px; + margin-bottom: 30px; + width: 100%; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + gap: 16px; + } +} + +.result-item { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + background: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; + flex: 1; + min-width: 200px; + + &.status-qualified { + background: #f0f9ff; + border-color: #4caf50; + + .result-icon { + color: #4caf50; + } + } + + &.status-not-qualified { + background: #fef2f2; + border-color: #ef4444; + + .result-icon { + color: #ef4444; + } + } +} + +.result-icon { + color: #3b1877; + font-size: 24px; + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.result-details { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.result-label { + font-size: 0.9rem; + color: #666; + font-weight: 500; +} + +.result-value { + font-size: 1.2rem; + color: #3b1877; + font-weight: 600; +} + +.result-actions { + margin-top: 30px; + + .finish-button, + .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; + cursor: pointer; + border: none; + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(59, 24, 119, 0.15); + transform: translateY(-1px); + } + + mat-icon { + margin-right: 8px; + } + } +} + +// Category breakdown styles +.category-breakdown { + margin-top: 30px; + padding: 20px; + background: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; + + .section-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 20px 0; + color: #3b1877; + font-size: 1.4rem; + font-weight: 700; + + mat-icon { + font-size: 24px; + } + } + + .category-list-container { + max-height: 300px; + overflow-y: auto; + padding-right: 4px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #3b1877; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #2d1259; + } + } + + .category-list { + display: flex; + flex-direction: column; + gap: 16px; + } + + .category-item { + background: #fff; + padding: 16px; + border-radius: 8px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + .category-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + .category-name { + font-weight: 600; + color: #3b1877; + font-size: 16px; + } + + .category-marks { + font-weight: 500; + color: #3b1877; + font-size: 16px; + font-weight: 600; + } + + .category-score { + color: #666; + font-size: 14px; + font-weight: 500; + } + } + + .progress-container { + .progress-bar { + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: #3b1877; + border-radius: 4px; + transition: width 0.3s ease; + } + } + } + } +} + +// Submission info styles +.submission-info { + display: flex; + align-items: center; + gap: 8px; + margin-top: 20px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + + .info-icon { + color: #667eea; + font-size: 18px; + } + + .info-text { + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + } +} + +// Responsive Design +@media (max-width: 768px) { + .result-container { + padding: 16px; + } + + .result-modal { + width: 95vw !important; + min-width: unset; + } + + .result-info-grid { + gap: 16px; + } + + .result-item { + padding: 12px; + gap: 12px; + } + + .result-header { + padding: 20px !important; + margin: -20px -20px 20px -20px !important; + + .mat-card-title { + font-size: 1.3rem !important; + } + } + + .loading-content, + .no-result-content { + padding: 30px 16px; + } +} diff --git a/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.ts b/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.ts new file mode 100644 index 0000000..475759f --- /dev/null +++ b/otpcasestudy-angular-app/src/app/components/exam-window/result-window/result-window.component.ts @@ -0,0 +1,116 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ExamSessionService } from '../../../services/exam-session.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { CandidateResultDTO, CategoryMarksDTO } from '../../../models/candidate-result.model'; + +@Component({ + selector: 'app-result-window', + standalone: true, + imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule], + templateUrl: './result-window.component.html', + styleUrl: './result-window.component.scss' +}) +export class ResultWindowComponent implements OnInit { + result: CandidateResultDTO | null = null; + loading = true; + + constructor( + private route: ActivatedRoute, + private examService: ExamSessionService, + private snackBar: MatSnackBar, + private router: Router + ) { } + + ngOnInit(): void { + // Prevent back navigation + history.pushState(null, '', location.href); + window.onpopstate = () => { + history.pushState(null, '', location.href); + } + + // Load exam result from session storage + this.loadExamResult(); + this.result = this.examService.getExamResultFromSession(); + console.log(this.result); + console.log(this.result?.categoryMarksList[0].categoryName); + + + } + + loadExamResult(): void { + try { + this.result = this.examService.getExamResultFromSession(); + if (!this.result) { + this.snackBar.open('No exam result found. Redirecting to home.', '', { duration: 3000 }); + setTimeout(() => { + this.router.navigate(['/landing']); + }, 10000); + } + } catch (error) { + console.error('Error loading exam result:', error); + this.snackBar.open('Error loading exam result.', '', { duration: 3000 }); + } finally { + this.loading = false; + } + } + + finishExam() { + // Clear exam result and session data + this.examService.clearExamResult(); + localStorage.removeItem('jwt_token'); + sessionStorage.clear(); + this.router.navigate(['/landing']); + } + + // Helper methods for displaying DTO data + getTotalMarks(): number { + if (!this.result || !this.result.categoryMarksList) return 0; + return this.result.categoryMarksList.reduce((total, category) => { + return total + (category.marksList?.reduce((sum, mark) => sum + mark, 0) || 0); + }, 0); + } + + getPercentage(): number { + const totalMarks = this.result?.totalMarks; + if (totalMarks === 0) return 0; + return Math.round((this.result?.marksObtained || 0) / totalMarks! * 100); + } + + isQualified(): boolean { + return this.result?.resultStatus === 'QUALIFIED'; + } + + getStatusText(): string { + return this.isQualified() ? 'Qualified' : 'Not Qualified'; + } + + getStatusClass(): string { + return this.isQualified() ? 'qualified' : 'not-qualified'; + } + + getCurrentTime(): Date { + return new Date(); + } + + // Helper methods for category calculations + getCategoryTotalMarks(category: CategoryMarksDTO): number { + return category.marksList?.reduce((sum, mark) => sum + mark, 0) || 0; + } + + getCategoryPercentage(category: CategoryMarksDTO): number { + const totalMarks = this.getCategoryTotalMarks(category); + if (totalMarks === 0) return 0; + return Math.round(((category.obtainedMarks || 0) / totalMarks) * 100); + } + + getCategoryProgressWidth(category: CategoryMarksDTO): number { + const totalMarks = this.getCategoryTotalMarks(category); + if (totalMarks === 0) return 0; + return ((category.obtainedMarks || 0) / totalMarks) * 100; + } +} 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..167ffb2 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/guards/admin-auth.guard.ts @@ -0,0 +1,12 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; + +export const AdminAuthGuard: CanActivateFn = (route, state) => { + const loggedIn = localStorage.getItem('otp_admin_logged_in') === 'true'; + if (!loggedIn) { + const router = inject(Router); + router.navigate(['/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 index 6c77770..d63907a 100644 --- a/otpcasestudy-angular-app/src/app/guards/exam-exit.guard.ts +++ b/otpcasestudy-angular-app/src/app/guards/exam-exit.guard.ts @@ -5,9 +5,16 @@ import { ExamWindowComponent } from '../components/exam-window/exam-window.compo @Injectable({ providedIn: 'root' }) export class ExamExitGuard implements CanDeactivate { canDeactivate(component: ExamWindowComponent): boolean { - if (component.examStarted) { + // Allow navigation if exam has ended (submitted) + if (component.examEnded) { + return true; + } + + // Prevent navigation if exam is started but not submitted + if (component.examStarted && !component.examEnded) { return confirm('Are you sure you want to leave the exam? Your progress may be lost.'); } + return true; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/otpcasestudy-angular-app/src/app/guards/stay-on-page.guard.ts b/otpcasestudy-angular-app/src/app/guards/stay-on-page.guard.ts new file mode 100644 index 0000000..ae49c11 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/guards/stay-on-page.guard.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@angular/core"; +import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, GuardResult, MaybeAsync } from "@angular/router"; + +@Injectable({ + providedIn: 'root' +}) +export class StayOnPageGuard implements CanActivate { + constructor(private router: Router) { } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + const isRestricted = true; + + if (isRestricted && state.url !== '/exam-result') { + this.router.navigate(['/exam-result']); + return false; + } + return true; + } +} diff --git a/otpcasestudy-angular-app/src/app/models/candidate-result.model.ts b/otpcasestudy-angular-app/src/app/models/candidate-result.model.ts new file mode 100644 index 0000000..2c35156 --- /dev/null +++ b/otpcasestudy-angular-app/src/app/models/candidate-result.model.ts @@ -0,0 +1,20 @@ +export interface CategoryMarksDTO { + id: number; + categoryName: string; + marksList: number[]; + obtainedMarks: number; +} + +export interface CandidateResultDTO { + candidateName: string; + marksObtained: number; + resultStatus: 'QUALIFIED' | 'NOT_QUALIFIED'; + totalMarks: number; + categoryMarksList: CategoryMarksDTO[]; +} + +export interface ExamResultResponse { + success: boolean; + message: string; + data: CandidateResultDTO; +} diff --git a/otpcasestudy-angular-app/src/app/models/exam-session.model.ts b/otpcasestudy-angular-app/src/app/models/exam-session.model.ts index 86d82e0..ff8cf34 100644 --- a/otpcasestudy-angular-app/src/app/models/exam-session.model.ts +++ b/otpcasestudy-angular-app/src/app/models/exam-session.model.ts @@ -6,27 +6,25 @@ export interface ExamSession { export interface ExamDetails { examId: number; - candidateName: string; - examName: string; - categories: ExamCategory[]; startTime: string; endTime: string; + questionCategoryList: ExamCategory[]; } export interface ExamCategory { - name: string; - sections: ExamSection[]; - locked: boolean; + id: number; + categoryName: string; + questionList: ExamQuestion[]; } -export interface ExamSection { - name: string; - questions: ExamQuestion[]; +export interface ExamQuestion { + id: number; + questionText: string; + questionOptionList: ExamOption[]; + marks: number; } -export interface ExamQuestion { - id: string; - text: string; - options: string[]; - answer?: string; +export interface ExamOption { + id: number; + optionText: 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 b3af632..772f1f5 100644 --- a/otpcasestudy-angular-app/src/app/models/exam.model.ts +++ b/otpcasestudy-angular-app/src/app/models/exam.model.ts @@ -31,17 +31,15 @@ export interface ExamResult { } export interface ExamScheduleSection { - category: string; + categoryName: string; marks: number; numberOfQuestions: number; + marksList: number[]; } export interface ExamSchedule { - id: string; - title: string; - key: string; - cutoff: number; - startDateTime: Date; - endDateTime: Date; - sections: ExamScheduleSection[]; + categoryId: number; + categoryName: string; + // sections: ExamScheduleSection[]; + marksList: any[]; } diff --git a/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts b/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts index 036e94e..8f15454 100644 --- a/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts +++ b/otpcasestudy-angular-app/src/app/services/auth-interceptor.service.ts @@ -6,6 +6,18 @@ import { Observable } from 'rxjs'; export class AuthInterceptor implements HttpInterceptor { intercept(req: HttpRequest, next: HttpHandler): Observable> { const token = localStorage.getItem('jwt_token'); + console.log(token); + + const skipURLs = [ + + '/admin-login', + '/registration', + '/verify-email' + ]; + if (skipURLs.some(url => req.url.includes(url))) { + return next.handle(req); + } + if (token) { const cloned = req.clone({ setHeaders: { diff --git a/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts b/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts index 77dd26a..036ff27 100644 --- a/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts +++ b/otpcasestudy-angular-app/src/app/services/candidate-registration.service.ts @@ -12,7 +12,9 @@ export class CandidateRegistrationService { register(candidate: any) { return this.http.post(`${environment.apiBaseUrl}/candidate`, candidate).pipe( tap((res) => { - const token = res?.data?.jwtToken || res?.data || res?.jwtToken || res; + const token = res?.data?.jwtToken; + console.log(token); + if (token) { localStorage.setItem('jwt_token', token); } diff --git a/otpcasestudy-angular-app/src/app/services/exam-session.service.ts b/otpcasestudy-angular-app/src/app/services/exam-session.service.ts index 0589eec..7944024 100644 --- a/otpcasestudy-angular-app/src/app/services/exam-session.service.ts +++ b/otpcasestudy-angular-app/src/app/services/exam-session.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, tap } from 'rxjs'; +import { environment } from '../../environments/environment.prod'; import { ExamResult } from '../models/exam-result.model'; -import { environment } from '../../environments/environment'; +import { CandidateResultDTO, ExamResultResponse } from '../models/candidate-result.model'; +import { ExamDetails, ExamCategory, ExamQuestion, ExamOption } from '../models/exam-session.model'; @Injectable({ providedIn: 'root' }) export class ExamSessionService { @@ -33,10 +35,10 @@ export class ExamSessionService { return this.http.get(`${this.apiUrl}/exam/start?examKey=${encodeURIComponent(examKey)}`); } - saveAnswerToSession(questionId: string, answer: any, markedForReview: boolean) { + saveAnswerToSession(questionId: number, answer: any) { const examData = this.getExamDataFromSession(); if (!examData.answers) examData.answers = {}; - examData.answers[questionId] = { answer, markedForReview }; + examData.answers[questionId] = { answer }; this.saveExamDataToSession(examData); } @@ -47,26 +49,49 @@ export class ExamSessionService { submitExamPayloadToBackend(): Observable { const examData = this.getExamDataFromSession(); - - const submitCategoryList = (examData.questions || []).map((cat: any) => { - const categoryQuestion: { [key: string]: any } = {}; - (cat.questions || []).forEach((q: any) => { + // const token = sessionStorage.getItem('jwt_token'); + const examSessionDataString = sessionStorage.getItem('exam_session_data'); + const examSessionData = examSessionDataString ? JSON.parse(examSessionDataString) : null; + const token = examSessionData?.jwtToken || ''; + const submitCategoryList = (examData.exam.questionCategoryList || []).map((cat: ExamCategory) => { + const categoryQuestion: { [key: number]: any } = {}; + (cat.questionList || []).forEach((q: ExamQuestion) => { if (examData.answers && examData.answers[q.id]) { categoryQuestion[q.id] = examData.answers[q.id].answer; } }); return { - categoryId: cat.id, + id: cat.id, categoryName: cat.categoryName, - categoryQuestion + categoryQuestion, }; }); const payload = { - examId: examData.exam?.id || examData.exam?.examId, - candidateId: examData.exam?.candidateId, + token, submitCategoryList }; - return this.http.post(`${this.apiUrl}/result/submit-exam`, payload); + console.log('Submitting exam payload:', payload); + + return this.http.post(`${this.apiUrl}/result/submit-exam`, payload).pipe( + tap((response: ExamResultResponse) => { + // Store the exam result in session storage for the result window + if (response && response.data) { + console.log('Exam result received:', response.data); + sessionStorage.setItem('exam_result', JSON.stringify(response.data)); + } else { + console.error('Invalid exam result response:', response); + } + }) + ); + } + + getExamResultFromSession(): CandidateResultDTO | null { + const resultData = sessionStorage.getItem('exam_result'); + return resultData ? JSON.parse(resultData) as CandidateResultDTO : null; + } + + clearExamResult(): void { + sessionStorage.removeItem('exam_result'); } getResult(examId: number): Observable { @@ -75,6 +100,7 @@ export class ExamSessionService { logout() { localStorage.removeItem('jwt_token'); + localStorage.clear(); 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 7b6e789..20a5f15 100644 --- a/otpcasestudy-angular-app/src/app/services/exam.service.ts +++ b/otpcasestudy-angular-app/src/app/services/exam.service.ts @@ -74,4 +74,19 @@ export class ExamService { return this.http.post(`${this.apiUrl}/admin/exam`, examSchedule) } + // Get registration status + getRegistrationStatus(): Observable { + return this.http.get(`${this.apiUrl}/admin/exam/registration-status`) + .pipe(map(res => { + const status = res.data; + return status === "ON" || status === true; + })); + } + + // Toggle registration status + toggleRegistration(): Observable { + return this.http.put(`${this.apiUrl}/admin/exam/toggle-registration`, {}) + .pipe(map(res => res.message)); + } + } diff --git a/otpcasestudy-angular-app/src/index.html b/otpcasestudy-angular-app/src/index.html index f4d7f98..2dc1a87 100644 --- a/otpcasestudy-angular-app/src/index.html +++ b/otpcasestudy-angular-app/src/index.html @@ -1,15 +1,21 @@ - + - - - OtpcasestudyAngularApp - - - - - - - - - + + + NextStep + + + + + + + + + diff --git a/otpcasestudy-angular-app/src/styles.scss b/otpcasestudy-angular-app/src/styles.scss index 9aea0c6..0f8dc65 100644 --- a/otpcasestudy-angular-app/src/styles.scss +++ b/otpcasestudy-angular-app/src/styles.scss @@ -95,6 +95,18 @@ body { border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important; } +.mat-slide-toggle.mat-slide-toggle-bar { + background-color: rgba(255, 255, 255, 0.3) !important; +} + +.mat-slide-toggle.mat-slide-toggle-thumb { + background-color: white !important; +} + +.mat-slide-toggle.mat-slide-toggle-checked .mat-slide-toggle-bar { + background-color: #4caf50 !important; +} + @media (max-width: 768px) { .mat-mdc-card { margin: 8px !important;