/** * Online Exams Module - Zustand Store */ import { create } from 'zustand'; import api from './auth'; interface Question { id: number; question_type: string; question: string; options: string[] | null; marks: number; difficulty: string; } interface ExamGroup { id: number; uid: string; name: string; description?: string; exam_type: string; duration_minutes: number; total_marks: number; passing_marks: number; max_attempts: number; show_results: boolean; allow_review: boolean; is_random_order: boolean; question_count?: number; questions?: Question[]; schedules?: any[]; } interface ExamAttempt { id: number; exam_group_id: number; started_at: string; submitted_at?: string; status: string; total_marks: number; obtained_marks: number; percentage: number; time_spent_seconds: number; allow_review: boolean; answers?: any[]; questions?: Question[]; } interface ExamAnswer { question_id: number; answer: string; } interface ExamStore { // State examGroups: ExamGroup[]; currentExam: ExamGroup | null; currentAttempt: ExamAttempt | null; studentAttempts: ExamAttempt[]; availableExams: any[]; answers: Map; timeRemaining: number; isSubmitting: boolean; error: string | null; // Actions fetchExamGroups: (filters?: any) => Promise; fetchExamGroup: (id: number) => Promise; createExamGroup: (data: Partial) => Promise; updateExamGroup: (id: number, data: Partial) => Promise; deleteExamGroup: (id: number) => Promise; // Questions fetchQuestions: (filters?: any) => Promise; createQuestion: (data: Partial) => Promise; bulkCreateQuestions: (questions: Partial[]) => Promise; updateQuestion: (id: number, data: Partial) => Promise; deleteQuestion: (id: number) => Promise; // Exams (Student) fetchAvailableExams: () => Promise; startExam: (examGroupId: number, scheduleId?: number) => Promise; saveAnswer: (questionId: number, answer: string) => Promise; submitExam: () => Promise; fetchAttemptResults: (attemptId: number) => Promise; // Results (Teacher) fetchExamResults: (filters?: any) => Promise; fetchStudentAttempts: (examGroupId?: number) => Promise; fetchExamStats: (examGroupId?: number) => Promise; // Timer startTimer: () => void; stopTimer: () => void; resetExam: () => void; } export const useExamStore = create((set, get) => ({ examGroups: [], currentExam: null, currentAttempt: null, studentAttempts: [], availableExams: [], answers: new Map(), timeRemaining: 0, isSubmitting: false, error: null, // ============ EXAM GROUPS ============ fetchExamGroups: async (filters) => { try { set({ error: null }); const params = new URLSearchParams(filters || {}); const response = await api.get(`/exams/groups?${params}`); set({ examGroups: response.data }); } catch (error: any) { set({ error: error.message }); console.error('Failed to fetch exam groups:', error); } }, fetchExamGroup: async (id) => { try { set({ error: null }); const response = await api.get(`/exams/groups/${id}`); set({ currentExam: response.data }); } catch (error: any) { set({ error: error.message }); console.error('Failed to fetch exam group:', error); } }, createExamGroup: async (data) => { const response = await api.post('/exams/groups', data); set(state => ({ examGroups: [response.data, ...state.examGroups] })); return response.data; }, updateExamGroup: async (id, data) => { const response = await api.put(`/exams/groups/${id}`, data); set(state => ({ examGroups: state.examGroups.map(g => g.id === id ? { ...g, ...response.data } : g), currentExam: state.currentExam?.id === id ? { ...state.currentExam, ...response.data } : state.currentExam })); }, deleteExamGroup: async (id) => { await api.delete(`/exams/groups/${id}`); set(state => ({ examGroups: state.examGroups.filter(g => g.id !== id) })); }, // ============ QUESTIONS ============ fetchQuestions: async (filters) => { const params = new URLSearchParams(filters || {}); const response = await api.get(`/exams/questions?${params}`); return response.data; }, createQuestion: async (data) => { const response = await api.post('/exams/questions', data); return response.data; }, bulkCreateQuestions: async (questions) => { await api.post('/exams/questions/bulk', { questions }); }, updateQuestion: async (id, data) => { await api.put(`/exams/questions/${id}`, data); }, deleteQuestion: async (id) => { await api.delete(`/exams/questions/${id}`); }, // ============ STUDENT EXAM ACTIONS ============ fetchAvailableExams: async () => { try { const response = await api.get('/exams/available'); set({ availableExams: response.data }); } catch (error: any) { console.error('Failed to fetch available exams:', error); } }, startExam: async (examGroupId, scheduleId) => { try { set({ error: null, isSubmitting: true }); const response = await api.post('/exams/attempts/start', { exam_group_id: examGroupId, schedule_id: scheduleId }); const attempt = response.data; // Initialize answers map const answersMap = new Map(); if (attempt.questions) { attempt.questions.forEach((q: Question) => { answersMap.set(q.id, ''); }); } set({ currentAttempt: attempt, currentExam: { ...attempt, questions: attempt.questions }, answers: answersMap, timeRemaining: (attempt.duration_minutes || 60) * 60, isSubmitting: false }); // Start timer get().startTimer(); } catch (error: any) { set({ error: error.response?.data?.error || 'Failed to start exam', isSubmitting: false }); console.error('Failed to start exam:', error); } }, saveAnswer: async (questionId, answer) => { const { currentAttempt, answers } = get(); // Save to local state immediately const newAnswers = new Map(answers); newAnswers.set(questionId, answer); set({ answers: newAnswers }); // Save to server (non-blocking) if (currentAttempt) { try { await api.post(`/exams/attempts/${currentAttempt.id}/answer`, { question_id: questionId, answer }); } catch (error) { console.error('Failed to save answer:', error); } } }, submitExam: async () => { const { currentAttempt, timeRemaining } = get(); if (!currentAttempt) return; set({ isSubmitting: true }); try { const response = await api.post(`/exams/attempts/${currentAttempt.id}/submit`); // Stop timer get().stopTimer(); set({ currentAttempt: response.data, isSubmitting: false }); return response.data; } catch (error: any) { set({ error: error.message, isSubmitting: false }); console.error('Failed to submit exam:', error); throw error; } }, fetchAttemptResults: async (attemptId) => { const response = await api.get(`/exams/attempts/${attemptId}`); set({ currentAttempt: response.data }); return response.data; }, fetchStudentAttempts: async (examGroupId) => { const params = examGroupId ? `?exam_group_id=${examGroupId}` : ''; const response = await api.get(`/exams/attempts${params}`); set({ studentAttempts: response.data }); }, // ============ TEACHER RESULTS ============ fetchExamResults: async (filters) => { const params = new URLSearchParams(filters || {}); const response = await api.get(`/exams/results?${params}`); return response.data; }, fetchExamStats: async (examGroupId) => { const url = examGroupId ? `/exams/stats?exam_group_id=${examGroupId}` : '/exams/stats'; const response = await api.get(url); return response.data; }, // ============ TIMER ============ startTimer: () => { const interval = setInterval(() => { const { timeRemaining } = get(); if (timeRemaining <= 0) { clearInterval(interval); // Auto-submit get().submitExam(); return; } set({ timeRemaining: timeRemaining - 1 }); }, 1000); // Store interval ID for cleanup set({ timeRemaining: get().timeRemaining }); // Just trigger update }, stopTimer: () => { // Timer will stop when component unmounts or exam is submitted }, resetExam: () => { set({ currentExam: null, currentAttempt: null, answers: new Map(), timeRemaining: 0, error: null }); } })); // Format time helper export const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; // Get question type icon export const getQuestionTypeIcon = (type: string): string => { const icons: Record = { 'multiple-choice': '☑️', 'true-false': '✓', 'short-answer': '✏️', 'essay': '📝', 'fill-blank': '📋' }; return icons[type] || '❓'; }; // Calculate grade export const calculateGrade = (percentage: number): { grade: string; color: string } => { if (percentage >= 90) return { grade: 'A+', color: '#10b981' }; if (percentage >= 80) return { grade: 'A', color: '#10b981' }; if (percentage >= 70) return { grade: 'B+', color: '#3b82f6' }; if (percentage >= 60) return { grade: 'B', color: '#3b82f6' }; if (percentage >= 50) return { grade: 'C', color: '#f59e0b' }; return { grade: 'F', color: '#ef4444' }; }; export default useExamStore;