You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
|
import { assessmentApi } from '@/src/utils/assessmentApi';
|
|
import { TranslationsContext } from '@/src/contexts/TranslationsContext';
|
|
import { useSwissOIDAuth } from 'swissoid-front';
|
|
import type {
|
|
Question,
|
|
ApiQuestion,
|
|
GradingOption,
|
|
AssessmentResult,
|
|
AssessmentProgress,
|
|
ServerAnswer,
|
|
LocalAnswer,
|
|
ContinueSessionResponse,
|
|
StartSessionResponse,
|
|
SaveAnswerResponse,
|
|
GetResultResponse
|
|
} from '@/src/types/assessment';
|
|
|
|
interface AssessmentContextType {
|
|
sessionId: string | null;
|
|
questions: Question[];
|
|
answersWithLabels: Record<number, { value: number; label: string }>; // Changed key from string to number
|
|
result: AssessmentResult | null;
|
|
progress: AssessmentProgress | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
countryMode: 'CH' | 'FR';
|
|
canModify: boolean;
|
|
isLocked: boolean;
|
|
|
|
startAssessment: (countryMode: 'CH' | 'FR') => Promise<void>;
|
|
submitAnswer: (questionId: number, value: number, label: string) => Promise<void>; // Changed questionId to number
|
|
submitAssessment: () => Promise<void>;
|
|
saveResult: (token: string | null) => Promise<void>;
|
|
lockSession: () => Promise<void>;
|
|
unlockSession: () => Promise<void>;
|
|
resetAssessment: () => void;
|
|
setCountryMode: (mode: 'CH' | 'FR') => void;
|
|
continueExistingSession: () => Promise<boolean>;
|
|
}
|
|
|
|
const AssessmentContext = createContext<AssessmentContextType | undefined>(undefined);
|
|
|
|
export function AssessmentProvider({ children }: { children: ReactNode }) {
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [questions, setQuestions] = useState<Question[]>([]);
|
|
const [answersWithLabels, setAnswersWithLabels] = useState<Record<number, { value: number; label: string }>>({});
|
|
const [result, setResult] = useState<AssessmentResult | null>(null);
|
|
const [progress, setProgress] = useState<AssessmentProgress | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [countryMode, setCountryMode] = useState<'CH' | 'FR'>('CH');
|
|
const [canModify, setCanModify] = useState(true);
|
|
const [isLocked, setIsLocked] = useState(false);
|
|
|
|
// Get locale from translations context
|
|
const translationsCtx = useContext(TranslationsContext);
|
|
const locale = translationsCtx?.locale;
|
|
|
|
// Get auth state
|
|
const { user } = useSwissOIDAuth();
|
|
const isLoggedIn = !!user;
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
|
|
|
// Load session from localStorage on mount
|
|
useEffect(() => {
|
|
const savedSessionId = localStorage.getItem('assessment_session_id');
|
|
if (savedSessionId) {
|
|
setSessionId(savedSessionId);
|
|
}
|
|
}, []);
|
|
|
|
// Save session to localStorage when it changes
|
|
useEffect(() => {
|
|
if (sessionId) {
|
|
localStorage.setItem('assessment_session_id', sessionId);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
// Associate session when user logs in
|
|
useEffect(() => {
|
|
if (isLoggedIn && sessionId && token) {
|
|
assessmentApi.associateSession(sessionId, token).catch(console.error);
|
|
}
|
|
}, [isLoggedIn, sessionId, token]);
|
|
|
|
const continueExistingSession = useCallback(async (): Promise<boolean> => {
|
|
const savedSessionId = localStorage.getItem('assessment_session_id');
|
|
if (!savedSessionId) return false;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await assessmentApi.continueSession(savedSessionId, locale) as ContinueSessionResponse;
|
|
|
|
if (!data) {
|
|
// Session not found
|
|
localStorage.removeItem('assessment_session_id');
|
|
return false;
|
|
}
|
|
|
|
// Handle both nested and direct data structures
|
|
const responseData = data.data || data;
|
|
console.log('Continue session response:', responseData);
|
|
|
|
if (data.status === 'success' && responseData) {
|
|
setSessionId(responseData.session_id);
|
|
|
|
// Process questions to add grading array from grading_types
|
|
const processedQuestions: Question[] = responseData.questions.map((q: ApiQuestion) => ({
|
|
...q,
|
|
grading: responseData.grading_types?.[q.grading_type] || []
|
|
}));
|
|
|
|
// Sort questions by display_order to ensure correct sequence
|
|
processedQuestions.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
|
|
|
setQuestions(processedQuestions);
|
|
|
|
// Convert new answer format to our internal format
|
|
// Continue session now returns existing_answers.answers array
|
|
if (responseData.existing_answers?.answers && Array.isArray(responseData.existing_answers.answers)) {
|
|
const answersMap: Record<number, number> = {};
|
|
const answersWithLabelsMap: Record<number, { value: number; label: string }> = {};
|
|
|
|
responseData.existing_answers.answers.forEach((answer: ServerAnswer) => {
|
|
// Find the question to get its grading type
|
|
const question = processedQuestions.find((q: Question) => q.id === answer.question_id);
|
|
let label = '';
|
|
|
|
// Reconstruct label from grading options since server doesn't provide it
|
|
if (question && question.grading) {
|
|
const gradingOption = question.grading.find((g: GradingOption) => g.value === answer.value);
|
|
label = gradingOption ? gradingOption.label : String(answer.value);
|
|
} else {
|
|
// Fallback to string value if no grading found
|
|
label = String(answer.value);
|
|
}
|
|
|
|
answersMap[answer.question_id] = answer.value;
|
|
answersWithLabelsMap[answer.question_id] = {
|
|
value: answer.value,
|
|
label: label
|
|
};
|
|
});
|
|
|
|
setAnswersWithLabels(answersWithLabelsMap);
|
|
|
|
const newProgress = {
|
|
level: responseData.calculated_level || 0,
|
|
confidence: responseData.confidence || 0,
|
|
total_points: responseData.total_points || 0,
|
|
is_complete: responseData.is_complete || false,
|
|
answered_count: responseData.existing_answers.total_answered || 0
|
|
};
|
|
console.log('Setting progress from continued session:', newProgress);
|
|
setProgress(newProgress);
|
|
} else {
|
|
setAnswersWithLabels({});
|
|
|
|
const newProgress = {
|
|
level: responseData.calculated_level || 0,
|
|
confidence: responseData.confidence || 0,
|
|
total_points: responseData.total_points || 0,
|
|
is_complete: responseData.is_complete || false,
|
|
answered_count: 0
|
|
};
|
|
console.log('Setting progress from continued session (no answers):', newProgress);
|
|
setProgress(newProgress);
|
|
}
|
|
|
|
setCountryMode(responseData.country_mode || 'CH');
|
|
setCanModify(responseData.can_modify !== false); // Default to true if not specified
|
|
setIsLocked(responseData.is_locked || false);
|
|
|
|
// If assessment is already complete, fetch the full result
|
|
if (responseData.is_complete) {
|
|
console.log('Continued session is already complete! Fetching full results...');
|
|
const resultData = await assessmentApi.getResult(responseData.session_id);
|
|
|
|
// Handle both nested and direct response structures
|
|
const resultInfo = resultData.data || resultData;
|
|
|
|
if (resultData.status === 'success' && resultInfo) {
|
|
console.log('Full result fetched for continued session:', resultInfo);
|
|
|
|
// Check if result is all zeros and use session data as fallback
|
|
if (resultInfo.level === 0 && resultInfo.total_points === 0 && responseData.calculated_level) {
|
|
console.warn('Result API returned zeros, using session data');
|
|
setResult({
|
|
level: responseData.calculated_level,
|
|
total_points: responseData.total_points || 0,
|
|
axis_breakdown: responseData.axis_breakdown || {
|
|
technical: 0,
|
|
tactical: 0,
|
|
competition: 0
|
|
},
|
|
confidence: responseData.confidence || 100,
|
|
unmet_criteria: []
|
|
});
|
|
} else {
|
|
setResult({
|
|
level: resultInfo.level,
|
|
total_points: resultInfo.total_points,
|
|
axis_breakdown: resultInfo.axis_breakdown || {
|
|
technical: 0,
|
|
tactical: 0,
|
|
competition: 0
|
|
},
|
|
confidence: resultInfo.confidence,
|
|
unmet_criteria: resultInfo.unmet_criteria || []
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
return false;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [locale]);
|
|
|
|
const startAssessment = useCallback(async (mode: 'CH' | 'FR') => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await assessmentApi.startSession(mode, locale, token || undefined) as StartSessionResponse;
|
|
|
|
if (data.status === 'success') {
|
|
// API returns session_id and questions at root level
|
|
setSessionId(data.session_id);
|
|
|
|
// Process questions - they might have grading inline or need it from grading_types
|
|
const processedQuestions: Question[] = data.questions.map((q: ApiQuestion) => {
|
|
// Always get grading from grading_types based on grading_type
|
|
return {
|
|
...q,
|
|
grading: data.grading_types?.[q.grading_type] || []
|
|
};
|
|
});
|
|
|
|
// Sort questions by display_order to ensure correct sequence
|
|
processedQuestions.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
|
|
|
setQuestions(processedQuestions);
|
|
setCountryMode(mode);
|
|
|
|
// Check for existing answers (authenticated users)
|
|
// Note: In start response, existing_answers is just an array, not wrapped
|
|
if (data.existing_answers && Array.isArray(data.existing_answers)) {
|
|
const answersMap: Record<number, number> = {};
|
|
const answersWithLabelsMap: Record<number, { value: number; label: string }> = {};
|
|
|
|
data.existing_answers.forEach((answer: ServerAnswer) => {
|
|
// Find the question to get its grading type
|
|
const question = processedQuestions.find((q: Question) => q.id === answer.question_id);
|
|
let label = '';
|
|
|
|
// Reconstruct label from grading options since server might not provide it
|
|
if (question && question.grading) {
|
|
const gradingOption = question.grading.find((g: GradingOption) => g.value === answer.value);
|
|
label = gradingOption ? gradingOption.label : String(answer.value);
|
|
} else {
|
|
// Fallback to string value if no grading found
|
|
label = String(answer.value);
|
|
}
|
|
|
|
answersWithLabelsMap[answer.question_id] = {
|
|
value: answer.value,
|
|
label: label
|
|
};
|
|
});
|
|
|
|
setAnswersWithLabels(answersWithLabelsMap);
|
|
setProgress({
|
|
level: data.calculated_level || 0,
|
|
confidence: data.confidence || 0,
|
|
total_points: data.total_points || 0,
|
|
is_complete: data.is_complete || false,
|
|
answered_count: data.existing_answers.length || 0
|
|
});
|
|
} else {
|
|
setAnswersWithLabels({});
|
|
setProgress({
|
|
level: 0,
|
|
confidence: 0,
|
|
total_points: 0,
|
|
is_complete: false,
|
|
answered_count: 0
|
|
});
|
|
}
|
|
setResult(null);
|
|
} else {
|
|
throw new Error(data.message || 'Failed to start assessment');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
throw err;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [locale, token]);
|
|
|
|
const submitAnswer = useCallback(async (questionId: number, value: number, label: string) => {
|
|
if (!sessionId) {
|
|
setError('No active session');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Save answer to backend FIRST (before updating local state)
|
|
const data = await assessmentApi.saveAnswer(sessionId, questionId, value, locale) as SaveAnswerResponse;
|
|
console.log('Raw backend response from saveAnswer:', data);
|
|
|
|
if (data.status !== 'success') return;
|
|
// NOW update the state after successful API response
|
|
setAnswersWithLabels(prev => ({
|
|
...prev,
|
|
[questionId]: { value, label }
|
|
}));
|
|
|
|
// Backend returns data directly, not nested under data field
|
|
const newProgress = {
|
|
level: data.level || 0,
|
|
confidence: data.confidence || 0,
|
|
total_points: data.total_points || 0,
|
|
is_complete: data.is_complete || false,
|
|
answered_count: Object.keys(answersWithLabels).length + 1
|
|
};
|
|
|
|
console.log('Setting progress after answer submission:', newProgress);
|
|
setProgress(newProgress);
|
|
|
|
if (!data.is_complete) return;
|
|
|
|
// If assessment is complete, fetch full result
|
|
console.log('Assessment complete! Fetching full results...');
|
|
// Fetch the full assessment result
|
|
const resultData = await assessmentApi.getResult(sessionId);
|
|
console.log('Full result response:', resultData);
|
|
|
|
// Handle both nested and direct response structures
|
|
const resultInfo = resultData.data || resultData;
|
|
|
|
if (resultData.status !== 'success' || !resultInfo) return;
|
|
console.log('Setting result from completion:', resultInfo);
|
|
|
|
// Check if the result has actual data or just zeros
|
|
if (resultInfo.level === 0 && resultInfo.total_points === 0) {
|
|
console.warn('Result has all zeros - backend may not have calculated properly');
|
|
// Use the progress data as fallback if available
|
|
if (data.level && data.total_points) {
|
|
console.log('Using progress data as fallback:', data);
|
|
setResult({
|
|
level: data.level,
|
|
total_points: data.total_points,
|
|
axis_breakdown: data.axis_breakdown || {
|
|
technical: 0,
|
|
tactical: 0,
|
|
competition: 0
|
|
},
|
|
confidence: data.confidence || 100,
|
|
unmet_criteria: []
|
|
});
|
|
} else {
|
|
setResult({
|
|
level: resultInfo.level,
|
|
total_points: resultInfo.total_points,
|
|
axis_breakdown: resultInfo.axis_breakdown || {
|
|
technical: 0,
|
|
tactical: 0,
|
|
competition: 0
|
|
},
|
|
confidence: resultInfo.confidence,
|
|
unmet_criteria: resultInfo.unmet_criteria || []
|
|
});
|
|
}
|
|
} else {
|
|
setResult({
|
|
level: resultInfo.level,
|
|
total_points: resultInfo.total_points,
|
|
axis_breakdown: resultInfo.axis_breakdown || {
|
|
technical: 0,
|
|
tactical: 0,
|
|
competition: 0
|
|
},
|
|
confidence: resultInfo.confidence,
|
|
unmet_criteria: resultInfo.unmet_criteria || []
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Don't update state on error - nothing to revert since we didn't update yet
|
|
setError(err instanceof Error ? err.message : 'Failed to save answer');
|
|
throw err;
|
|
}
|
|
}, [sessionId, answersWithLabels, locale]);
|
|
|
|
const submitAssessment = useCallback(async () => {
|
|
if (!sessionId) {
|
|
setError('No active session');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await assessmentApi.submitAnswers(sessionId, Object.entries(answersWithLabels).reduce(
|
|
(p, [id, { value }]) => ({ ...p, [id]: value, }),
|
|
{}
|
|
));
|
|
|
|
if (data.status === 'success') {
|
|
// Check if result is at root level or nested
|
|
const result = data.result || data.data?.result;
|
|
if (result) {
|
|
setResult(result);
|
|
} else {
|
|
throw new Error('No result returned from assessment');
|
|
}
|
|
} else {
|
|
throw new Error(data.message || 'Failed to submit assessment');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
throw err;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [sessionId, answersWithLabels]);
|
|
|
|
const saveResult = useCallback(async (token: string | null) => {
|
|
if (!sessionId) {
|
|
setError('No active session');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await assessmentApi.saveResult(sessionId, token);
|
|
|
|
if (data.status === 'success' || data.level !== undefined) {
|
|
// Successfully saved
|
|
setIsLocked(true);
|
|
setCanModify(false);
|
|
} else {
|
|
throw new Error(data.message || 'Failed to save result');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
throw err;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const lockSession = useCallback(async () => {
|
|
if (!sessionId) {
|
|
setError('No active session');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Pass null for token - the API will use cookies for auth
|
|
const data = await assessmentApi.lockSession(sessionId, null);
|
|
|
|
if (data.status === 'success') {
|
|
setIsLocked(true);
|
|
setCanModify(false);
|
|
console.log('Session locked successfully');
|
|
return data.data;
|
|
} else {
|
|
throw new Error(data.message || 'Failed to lock session');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to lock session');
|
|
throw err;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const unlockSession = useCallback(async () => {
|
|
if (!sessionId) {
|
|
setError('No active session');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await assessmentApi.unlockSession(sessionId, token);
|
|
|
|
if (data.status === 'success') {
|
|
setIsLocked(false);
|
|
setCanModify(true);
|
|
console.log('Session unlocked successfully');
|
|
return data.data;
|
|
} else {
|
|
throw new Error(data.message || 'Failed to unlock session');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to unlock session');
|
|
throw err;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [sessionId, token]);
|
|
|
|
const resetAssessment = useCallback(() => {
|
|
setSessionId(null);
|
|
setQuestions([]);
|
|
setAnswersWithLabels({});
|
|
setResult(null);
|
|
setProgress(null);
|
|
setError(null);
|
|
setCanModify(true);
|
|
setIsLocked(false);
|
|
localStorage.removeItem('assessment_session_id');
|
|
}, []);
|
|
|
|
const value: AssessmentContextType = {
|
|
sessionId,
|
|
questions,
|
|
answersWithLabels,
|
|
result,
|
|
progress,
|
|
isLoading,
|
|
error,
|
|
countryMode,
|
|
canModify,
|
|
isLocked,
|
|
startAssessment,
|
|
submitAnswer,
|
|
submitAssessment,
|
|
saveResult,
|
|
lockSession,
|
|
unlockSession,
|
|
resetAssessment,
|
|
setCountryMode,
|
|
continueExistingSession
|
|
};
|
|
|
|
return (
|
|
<AssessmentContext.Provider value={value}>
|
|
{children}
|
|
</AssessmentContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAssessment() {
|
|
const context = useContext(AssessmentContext);
|
|
if (context === undefined) {
|
|
throw new Error('useAssessment must be used within an AssessmentProvider');
|
|
}
|
|
return context;
|
|
} |