366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
/**
|
|
* 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<number, string>;
|
|
timeRemaining: number;
|
|
isSubmitting: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchExamGroups: (filters?: any) => Promise<void>;
|
|
fetchExamGroup: (id: number) => Promise<void>;
|
|
createExamGroup: (data: Partial<ExamGroup>) => Promise<ExamGroup>;
|
|
updateExamGroup: (id: number, data: Partial<ExamGroup>) => Promise<void>;
|
|
deleteExamGroup: (id: number) => Promise<void>;
|
|
|
|
// Questions
|
|
fetchQuestions: (filters?: any) => Promise<Question[]>;
|
|
createQuestion: (data: Partial<Question>) => Promise<Question>;
|
|
bulkCreateQuestions: (questions: Partial<Question>[]) => Promise<void>;
|
|
updateQuestion: (id: number, data: Partial<Question>) => Promise<void>;
|
|
deleteQuestion: (id: number) => Promise<void>;
|
|
|
|
// Exams (Student)
|
|
fetchAvailableExams: () => Promise<void>;
|
|
startExam: (examGroupId: number, scheduleId?: number) => Promise<void>;
|
|
saveAnswer: (questionId: number, answer: string) => Promise<void>;
|
|
submitExam: () => Promise<void>;
|
|
fetchAttemptResults: (attemptId: number) => Promise<void>;
|
|
|
|
// Results (Teacher)
|
|
fetchExamResults: (filters?: any) => Promise<any[]>;
|
|
fetchStudentAttempts: (examGroupId?: number) => Promise<void>;
|
|
fetchExamStats: (examGroupId?: number) => Promise<any>;
|
|
|
|
// Timer
|
|
startTimer: () => void;
|
|
stopTimer: () => void;
|
|
resetExam: () => void;
|
|
}
|
|
|
|
export const useExamStore = create<ExamStore>((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<number, string>();
|
|
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<string, string> = {
|
|
'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; |