2025-10-17 14:16:58 +00:00
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
|
|
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
|
|
|
|
import { QuestionRenderer } from './QuestionRenderer'
|
|
|
|
|
|
import { ExamNavigation } from './ExamNavigation'
|
|
|
|
|
|
import { ExamTimer } from './ExamTimer'
|
|
|
|
|
|
import { SecurityWarning } from './SecurityWarning'
|
|
|
|
|
|
import { ExamSession, StudentAnswer } from '@/types/coordinator'
|
|
|
|
|
|
import { useExamSecurity } from '@/utils/hooks/useExamSecurity'
|
|
|
|
|
|
import { useStoreState } from '@/store/store'
|
|
|
|
|
|
import { generateMockExam } from '@/mocks/mockExams'
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
const ExamInterface: React.FC = () => {
|
|
|
|
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const userId = useStoreState((state) => state.auth.user.id)
|
|
|
|
|
|
|
|
|
|
|
|
// TODO: Replace with actual API call to fetch exam by id
|
|
|
|
|
|
const exam = generateMockExam().find((e) => e.id === id)
|
|
|
|
|
|
|
|
|
|
|
|
if (!exam) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
|
|
|
|
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6 text-center">
|
|
|
|
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Sınav Bulunamadı</h2>
|
|
|
|
|
|
<p className="text-gray-600 mb-6">İstenen sınav bulunamadı veya erişim yetkiniz yok.</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
Geri Dön
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
const [session, setSession] = useState<ExamSession>({
|
|
|
|
|
|
id: `session-${Date.now()}`,
|
|
|
|
|
|
examId: exam.id,
|
2025-10-17 14:16:58 +00:00
|
|
|
|
studentId: userId,
|
2025-10-15 19:52:01 +00:00
|
|
|
|
startTime: new Date(),
|
|
|
|
|
|
answers: [],
|
|
|
|
|
|
status: 'in-progress',
|
2025-10-17 14:16:58 +00:00
|
|
|
|
timeRemaining: exam.timeLimit * 60,
|
|
|
|
|
|
})
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
2025-10-15 19:52:01 +00:00
|
|
|
|
const [securityWarning, setSecurityWarning] = useState<{
|
2025-10-17 14:16:58 +00:00
|
|
|
|
show: boolean
|
|
|
|
|
|
message: string
|
|
|
|
|
|
type: 'warning' | 'error' | 'info'
|
|
|
|
|
|
}>({ show: false, message: '', type: 'warning' })
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
// Security configuration
|
|
|
|
|
|
const securityConfig = {
|
|
|
|
|
|
disableRightClick: true,
|
|
|
|
|
|
disableCopyPaste: true,
|
|
|
|
|
|
disableDevTools: true,
|
|
|
|
|
|
fullScreenMode: true,
|
2025-10-17 14:16:58 +00:00
|
|
|
|
preventTabSwitch: true,
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
useExamSecurity(securityConfig, session.status === 'in-progress')
|
|
|
|
|
|
|
|
|
|
|
|
// Save exam session to backend/localStorage
|
|
|
|
|
|
const saveExamSession = (sessionData: ExamSession) => {
|
|
|
|
|
|
// TODO: Replace with actual API call
|
|
|
|
|
|
console.log('Saving exam session:', sessionData)
|
|
|
|
|
|
localStorage.setItem(`exam-session-${sessionData.examId}`, JSON.stringify(sessionData))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Complete exam and navigate
|
|
|
|
|
|
const handleExamComplete = (sessionData: ExamSession) => {
|
|
|
|
|
|
// TODO: Replace with actual API call
|
|
|
|
|
|
console.log('Exam completed:', sessionData)
|
|
|
|
|
|
saveExamSession(sessionData)
|
|
|
|
|
|
// Navigate to results or dashboard
|
|
|
|
|
|
// navigate('/admin/coordinator/exams')
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
// Auto-save mechanism
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const interval = setInterval(() => {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
if (session.status === 'in-progress') {
|
|
|
|
|
|
saveExamSession(session)
|
2025-10-15 19:52:01 +00:00
|
|
|
|
}
|
2025-10-17 14:16:58 +00:00
|
|
|
|
}, 30000) // Save every 30 seconds
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
return () => clearInterval(interval)
|
|
|
|
|
|
}, [session])
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
const handleAnswerChange = (questionId: string, answer: string | string[]) => {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
setSession((prev) => {
|
|
|
|
|
|
const existingAnswerIndex = prev.answers.findIndex((a) => a.questionId === questionId)
|
2025-10-15 19:52:01 +00:00
|
|
|
|
const newAnswer: StudentAnswer = {
|
|
|
|
|
|
questionId,
|
|
|
|
|
|
answer,
|
2025-10-17 14:16:58 +00:00
|
|
|
|
timeSpent: 0, // In a real app, track time spent per question
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
const newAnswers =
|
|
|
|
|
|
existingAnswerIndex >= 0
|
|
|
|
|
|
? prev.answers.map((a, i) => (i === existingAnswerIndex ? newAnswer : a))
|
|
|
|
|
|
: [...prev.answers, newAnswer]
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...prev,
|
2025-10-17 14:16:58 +00:00
|
|
|
|
answers: newAnswers,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
const handleTimeUp = () => {
|
|
|
|
|
|
setSecurityWarning({
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
message: 'Süre doldu! Sınav otomatik olarak teslim ediliyor...',
|
2025-10-17 14:16:58 +00:00
|
|
|
|
type: 'error',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-15 19:52:01 +00:00
|
|
|
|
setTimeout(() => {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
completeExam()
|
|
|
|
|
|
}, 3000)
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
const completeExam = () => {
|
|
|
|
|
|
const completedSession: ExamSession = {
|
|
|
|
|
|
...session,
|
|
|
|
|
|
endTime: new Date(),
|
2025-10-17 14:16:58 +00:00
|
|
|
|
status: 'completed',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSession(completedSession)
|
|
|
|
|
|
handleExamComplete(completedSession)
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
const handleSubmitExam = () => {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
const unanswered = exam.questions.filter(
|
|
|
|
|
|
(q) => !session.answers.find((a) => a.questionId === q.id && a.answer),
|
|
|
|
|
|
)
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
if (unanswered.length > 0) {
|
|
|
|
|
|
const confirmSubmit = window.confirm(
|
2025-10-17 14:16:58 +00:00
|
|
|
|
`${unanswered.length} soru cevaplanmamış. Yine de sınavı teslim etmek istiyor musunuz?`,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (!confirmSubmit) return
|
2025-10-15 19:52:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
completeExam()
|
|
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
2025-10-17 14:16:58 +00:00
|
|
|
|
const currentQuestion = exam.questions[currentQuestionIndex]
|
|
|
|
|
|
const currentAnswer = session.answers.find((a) => a.questionId === currentQuestion?.id)
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
const navigateQuestion = (direction: 'next' | 'prev' | number) => {
|
|
|
|
|
|
if (typeof direction === 'number') {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
setCurrentQuestionIndex(Math.max(0, Math.min(exam.questions.length - 1, direction)))
|
2025-10-15 19:52:01 +00:00
|
|
|
|
} else if (direction === 'next') {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
setCurrentQuestionIndex((prev) => Math.min(exam.questions.length - 1, prev + 1))
|
2025-10-15 19:52:01 +00:00
|
|
|
|
} else {
|
2025-10-17 14:16:58 +00:00
|
|
|
|
setCurrentQuestionIndex((prev) => Math.max(0, prev - 1))
|
2025-10-15 19:52:01 +00:00
|
|
|
|
}
|
2025-10-17 14:16:58 +00:00
|
|
|
|
}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
|
|
|
|
|
|
if (session.status === 'completed') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
|
|
|
|
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6 text-center">
|
|
|
|
|
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
2025-10-17 14:16:58 +00:00
|
|
|
|
<svg
|
|
|
|
|
|
className="w-8 h-8 text-green-600"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
|
d="M5 13l4 4L19 7"
|
|
|
|
|
|
/>
|
2025-10-15 19:52:01 +00:00
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Sınav Tamamlandı!</h2>
|
|
|
|
|
|
<p className="text-gray-600 mb-6">
|
2025-10-17 14:16:58 +00:00
|
|
|
|
Sınavınız başarıyla teslim edildi. Sonuçlarınız değerlendirildikten sonra
|
|
|
|
|
|
bilgilendirileceksiniz.
|
2025-10-15 19:52:01 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="text-sm text-gray-500">
|
2025-10-17 14:16:58 +00:00
|
|
|
|
Başlama: {session.startTime.toLocaleString('tr-TR')}
|
|
|
|
|
|
<br />
|
2025-10-15 19:52:01 +00:00
|
|
|
|
Bitiş: {session.endTime?.toLocaleString('tr-TR')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-17 14:16:58 +00:00
|
|
|
|
)
|
2025-10-15 19:52:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-screen bg-gray-50">
|
|
|
|
|
|
<SecurityWarning
|
|
|
|
|
|
isVisible={securityWarning.show}
|
2025-10-17 14:16:58 +00:00
|
|
|
|
onDismiss={() => setSecurityWarning((prev) => ({ ...prev, show: false }))}
|
2025-10-15 19:52:01 +00:00
|
|
|
|
message={securityWarning.message}
|
|
|
|
|
|
type={securityWarning.type}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<header className="bg-white border-b border-gray-200 px-4 py-3">
|
|
|
|
|
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-xl font-semibold text-gray-900">{exam.title}</h1>
|
|
|
|
|
|
<p className="text-sm text-gray-600">
|
|
|
|
|
|
Soru {currentQuestionIndex + 1} / {exam.questions.length}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-10-17 14:16:58 +00:00
|
|
|
|
|
2025-10-15 19:52:01 +00:00
|
|
|
|
<div className="flex items-center space-x-4">
|
2025-10-17 14:16:58 +00:00
|
|
|
|
<ExamTimer initialTime={exam.timeLimit * 60} onTimeUp={handleTimeUp} autoStart={true} />
|
|
|
|
|
|
|
2025-10-15 19:52:01 +00:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleSubmitExam}
|
|
|
|
|
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
Sınavı Teslim Et
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Main Content */}
|
|
|
|
|
|
<div className="max-w-7xl mx-auto p-4">
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
|
|
|
|
{/* Question Content */}
|
|
|
|
|
|
<div className="lg:col-span-3 space-y-6">
|
|
|
|
|
|
{currentQuestion && (
|
|
|
|
|
|
<QuestionRenderer
|
|
|
|
|
|
question={currentQuestion}
|
|
|
|
|
|
answer={currentAnswer}
|
|
|
|
|
|
onAnswerChange={handleAnswerChange}
|
|
|
|
|
|
disabled={session.status !== 'in-progress'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Navigation Controls */}
|
|
|
|
|
|
<div className="flex items-center justify-between bg-white border border-gray-200 rounded-lg p-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => navigateQuestion('prev')}
|
|
|
|
|
|
disabled={currentQuestionIndex === 0}
|
|
|
|
|
|
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 text-gray-700 rounded-lg font-medium transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
2025-10-17 14:16:58 +00:00
|
|
|
|
<path
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
|
d="M15 19l-7-7 7-7"
|
|
|
|
|
|
/>
|
2025-10-15 19:52:01 +00:00
|
|
|
|
</svg>
|
|
|
|
|
|
<span>Önceki</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
<span className="text-sm text-gray-600">
|
|
|
|
|
|
{currentQuestionIndex + 1} / {exam.questions.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => navigateQuestion('next')}
|
|
|
|
|
|
disabled={currentQuestionIndex === exam.questions.length - 1}
|
|
|
|
|
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-50 disabled:text-gray-400 text-white rounded-lg font-medium transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>Sonraki</span>
|
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
2025-10-17 14:16:58 +00:00
|
|
|
|
<path
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
|
d="M9 5l7 7-7 7"
|
|
|
|
|
|
/>
|
2025-10-15 19:52:01 +00:00
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Navigation Sidebar */}
|
|
|
|
|
|
<div className="lg:col-span-1">
|
|
|
|
|
|
<ExamNavigation
|
|
|
|
|
|
questions={exam.questions}
|
|
|
|
|
|
answers={session.answers}
|
|
|
|
|
|
currentQuestionIndex={currentQuestionIndex}
|
|
|
|
|
|
onQuestionSelect={navigateQuestion}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-17 14:16:58 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ExamInterface
|