geocrop-platform./apps/nextgen/client/src/store/exams.ts

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;