UNPKG

analytica-frontend-lib

Version:

Repositório público dos componentes utilizados nas plataformas da Analytica Ensino

1 lines 104 kB
{"version":3,"sources":["../src/components/CorrectActivityModal/CorrectActivityModal.tsx","../src/utils/studentActivityCorrection/constants.ts","../src/utils/studentActivityCorrection/utils.ts","../src/utils/studentActivityCorrection/converter.ts","../src/utils/questionRenderer/alternative/index.tsx","../src/utils/questionRenderer/multipleChoice/index.tsx","../src/utils/questionRenderer/components/index.tsx","../src/utils/questionRenderer/trueOrFalse/index.tsx","../src/utils/questionRenderer/dissertative/index.tsx","../src/utils/questionRenderer/fill/index.tsx","../src/utils/questionRenderer/image/index.tsx","../src/utils/questionRenderer/connectDots/index.tsx","../src/utils/questionRenderer/index.tsx"],"sourcesContent":["import {\n useState,\n useRef,\n useEffect,\n useCallback,\n type ReactNode,\n} from 'react';\nimport { PencilSimpleIcon } from '@phosphor-icons/react/dist/csr/PencilSimple';\nimport { PaperclipIcon } from '@phosphor-icons/react/dist/csr/Paperclip';\nimport { XIcon } from '@phosphor-icons/react/dist/csr/X';\nimport { ImageIcon } from '@phosphor-icons/react/dist/csr/Image';\nimport Modal from '../Modal/Modal';\nimport Text from '../Text/Text';\nimport Button from '../Button/Button';\nimport Badge from '../Badge/Badge';\nimport Radio from '../Radio/Radio';\nimport TextArea from '../TextArea/TextArea';\nimport { CardAccordation, AccordionGroup } from '../Accordation';\nimport { generateFileId } from '../FileAttachment/FileAttachment';\nimport type { AttachedFile } from '../FileAttachment/FileAttachment';\nimport { StatCard } from '../shared/StatCard';\nimport { cn } from '../../utils/utils';\nimport { QUESTION_TYPE } from '../Quiz/useQuizStore';\nimport {\n type StudentActivityCorrectionData,\n type SaveQuestionCorrectionPayload,\n getQuestionStatusBadgeConfig,\n getQuestionStatusFromData,\n QUESTION_STATUS,\n} from '../../utils/studentActivityCorrection';\nimport {\n renderQuestionAlternative,\n renderQuestionMultipleChoice,\n renderQuestionTrueOrFalse,\n renderQuestionDissertative,\n renderQuestionFill,\n renderQuestionImage,\n renderQuestionConnectDots,\n} from '../../utils/questionRenderer/index';\nimport useToastStore from '../Toast/utils/ToastStore';\nimport { HtmlMathRenderer } from '../HtmlMathRenderer';\n\n/**\n * Props for the CorrectActivityModal component\n */\nexport interface CorrectActivityModalProps {\n /** Whether the modal is open */\n isOpen: boolean;\n /** Function to close the modal */\n onClose: () => void;\n /** Student activity correction data */\n data: StudentActivityCorrectionData | null;\n /** Whether the modal is in view-only mode */\n isViewOnly?: boolean;\n /** Callback when observation is submitted with optional files */\n onObservationSubmit?: (\n studentId: string,\n observation: string,\n files: File[],\n existingAttachment: string | null\n ) => void;\n /** Callback when question correction is saved (for essay questions) */\n onQuestionCorrectionSubmit?: (\n studentId: string,\n payload: SaveQuestionCorrectionPayload\n ) => Promise<void>;\n /** URL of the scanned answer sheet image (for exam mode) */\n answerSheetImageUrl?: string | null;\n /** Callback when \"Ver gabarito escaneado\" button is clicked */\n onViewScannedAnswerSheet?: () => void;\n}\n\n/** Field names for essay correction state updates */\nconst EssayCorrectionField = {\n IsCorrect: 'isCorrect',\n TeacherFeedback: 'teacherFeedback',\n} as const;\n\n/**\n * Modal component for correcting or viewing student activity details\n *\n * Displays student information, statistics cards, observation section,\n * and a list of questions with their status.\n *\n * @param props - Component props\n * @returns JSX element\n *\n * @example\n * ```tsx\n * <CorrectActivityModal\n * isOpen={isOpen}\n * onClose={() => setIsOpen(false)}\n * data={studentData}\n * isViewOnly={false}\n * onObservationSubmit={(obs, files) => console.log(obs, files)}\n * />\n * ```\n */\nconst CorrectActivityModal = ({\n isOpen,\n onClose,\n data,\n isViewOnly = false,\n onObservationSubmit,\n onQuestionCorrectionSubmit,\n answerSheetImageUrl,\n onViewScannedAnswerSheet,\n}: CorrectActivityModalProps) => {\n const [observation, setObservation] = useState('');\n const [isObservationExpanded, setIsObservationExpanded] = useState(false);\n const [isObservationSaved, setIsObservationSaved] = useState(false);\n const [savedObservation, setSavedObservation] = useState('');\n const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);\n const [savedFiles, setSavedFiles] = useState<AttachedFile[]>([]);\n const [existingAttachment, setExistingAttachment] = useState<string | null>(\n null\n );\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n // Toast store for notifications\n const addToast = useToastStore((state) => state.addToast);\n\n // State for essay question corrections\n const [essayCorrections, setEssayCorrections] = useState<\n Record<\n number,\n {\n isCorrect: boolean | null;\n teacherFeedback: string;\n isSaving: boolean;\n isSaved: boolean;\n }\n >\n >({});\n\n /**\n * Reset state when modal opens or student changes\n * Load existing observation and attachment if available\n */\n useEffect(() => {\n if (isOpen) {\n setObservation('');\n setIsObservationExpanded(false);\n setAttachedFiles([]);\n setSavedFiles([]);\n setExistingAttachment(data?.attachment ?? null);\n\n // Load existing observation/attachment if available\n if (data?.observation || data?.attachment) {\n setIsObservationSaved(true);\n setSavedObservation(data.observation || '');\n } else {\n setIsObservationSaved(false);\n setSavedObservation('');\n }\n\n // Initialize essay corrections from data\n const initialCorrections: Record<\n number,\n {\n isCorrect: boolean | null;\n teacherFeedback: string;\n isSaving: boolean;\n isSaved: boolean;\n }\n > = {};\n data?.questions?.forEach((questionData) => {\n if (questionData.question.questionType === QUESTION_TYPE.DISSERTATIVA) {\n initialCorrections[questionData.questionNumber] = {\n isCorrect: questionData.correction?.isCorrect ?? null,\n teacherFeedback: questionData.correction?.teacherFeedback || '',\n isSaving: false,\n isSaved: questionData.correction?.isCorrect != null,\n };\n }\n });\n setEssayCorrections(initialCorrections);\n }\n }, [\n isOpen,\n data?.studentId,\n data?.observation,\n data?.attachment,\n data?.questions,\n ]);\n\n /**\n * Handle opening observation section\n */\n const handleOpenObservation = () => {\n setIsObservationExpanded(true);\n };\n\n /**\n * Handle adding files (single file mode - replaces existing file)\n * @param files - Files to add\n */\n const handleFilesAdd = (files: File[]) => {\n const newFile = files[0];\n if (newFile) {\n setAttachedFiles([{ file: newFile, id: generateFileId() }]);\n }\n };\n\n /**\n * Handle removing a file\n * @param id - File ID to remove\n */\n const handleFileRemove = (id: string) => {\n setAttachedFiles((prev) => prev.filter((f) => f.id !== id));\n };\n\n /**\n * Handle saving observation\n */\n const handleSaveObservation = () => {\n if (observation.trim() || attachedFiles.length > 0 || existingAttachment) {\n // Validate studentId before saving to prevent silent no-op\n if (!data?.studentId) {\n return;\n }\n\n setSavedObservation(observation);\n setSavedFiles([...attachedFiles]);\n setIsObservationSaved(true);\n setIsObservationExpanded(false);\n\n // Pass studentId explicitly from data prop to avoid stale closure issues\n onObservationSubmit?.(\n data.studentId,\n observation,\n attachedFiles.map((f) => f.file),\n existingAttachment\n );\n }\n };\n\n /**\n * Handle editing observation\n */\n const handleEditObservation = () => {\n setObservation(savedObservation);\n setAttachedFiles([...savedFiles]);\n setIsObservationSaved(false);\n setIsObservationExpanded(true);\n };\n\n /**\n * Handle saving essay question correction\n */\n const handleSaveEssayCorrection = useCallback(\n async (questionNumber: number) => {\n if (!data?.studentId || !onQuestionCorrectionSubmit) return;\n\n const correction = essayCorrections[questionNumber];\n if (correction?.isCorrect == null) {\n return;\n }\n\n setEssayCorrections((prev) => ({\n ...prev,\n [questionNumber]: { ...prev[questionNumber], isSaving: true },\n }));\n\n try {\n // Find the question data to get questionId\n const questionData = data?.questions.find(\n (q) => q.questionNumber === questionNumber\n );\n if (!questionData) {\n console.error('Questão não encontrada:', questionNumber);\n return;\n }\n\n await onQuestionCorrectionSubmit(data.studentId, {\n questionId: questionData.question.id,\n isCorrect: correction.isCorrect,\n teacherFeedback: correction.teacherFeedback,\n });\n\n // Mark as saved and show success toast\n setEssayCorrections((prev) => ({\n ...prev,\n [questionNumber]: {\n ...prev[questionNumber],\n isSaving: false,\n isSaved: true,\n },\n }));\n\n addToast({\n title: 'Correção salva',\n description: `A correção da questão ${questionNumber} foi salva com sucesso.`,\n variant: 'solid',\n action: 'success',\n position: 'top-right',\n });\n } catch (error) {\n console.error('Erro ao salvar correção da questão:', error);\n\n setEssayCorrections((prev) => ({\n ...prev,\n [questionNumber]: { ...prev[questionNumber], isSaving: false },\n }));\n\n addToast({\n title: 'Erro ao salvar correção',\n description: 'Não foi possível salvar a correção. Tente novamente.',\n variant: 'solid',\n action: 'warning',\n position: 'top-right',\n });\n }\n },\n [\n data?.studentId,\n data?.questions,\n essayCorrections,\n onQuestionCorrectionSubmit,\n addToast,\n ]\n );\n\n /**\n * Update essay correction state\n * Resets isSaved to false when isCorrect changes so badge only updates after saving\n */\n const updateEssayCorrection = useCallback(\n (\n questionNumber: number,\n field:\n | (typeof EssayCorrectionField)['IsCorrect']\n | (typeof EssayCorrectionField)['TeacherFeedback'],\n value: boolean | string\n ) => {\n setEssayCorrections((prev) => ({\n ...prev,\n [questionNumber]: {\n ...prev[questionNumber],\n [field]: value,\n // Reset isSaved when isCorrect changes so badge doesn't update until saved\n ...(field === EssayCorrectionField.IsCorrect\n ? { isSaved: false }\n : {}),\n },\n }));\n },\n []\n );\n\n /**\n * Get accordion title based on question type\n */\n const getAccordionTitle = (questionType: QUESTION_TYPE): string => {\n switch (questionType) {\n case QUESTION_TYPE.ALTERNATIVA:\n case QUESTION_TYPE.MULTIPLA_ESCOLHA:\n case QUESTION_TYPE.VERDADEIRO_FALSO:\n return 'Alternativas';\n case QUESTION_TYPE.DISSERTATIVA:\n return 'Resposta';\n case QUESTION_TYPE.PREENCHER_LACUNAS:\n return 'Preencher Lacunas';\n case QUESTION_TYPE.IMAGEM:\n return 'Imagem';\n case QUESTION_TYPE.RELACIONAR:\n return 'Ligar Pontos';\n default:\n return 'Resposta';\n }\n };\n\n /**\n * Render question content using Quiz format renderers with accordion\n */\n const renderQuestionContent = (\n questionData: StudentActivityCorrectionData['questions'][number]\n ) => {\n const { question, result } = questionData;\n const questionType = question.questionType;\n const accordionTitle = getAccordionTitle(questionType);\n\n let content: ReactNode;\n\n switch (questionType) {\n case QUESTION_TYPE.ALTERNATIVA:\n content = renderQuestionAlternative({\n question,\n result,\n });\n break;\n case QUESTION_TYPE.MULTIPLA_ESCOLHA:\n content = renderQuestionMultipleChoice({\n question,\n result,\n });\n break;\n case QUESTION_TYPE.VERDADEIRO_FALSO:\n content = renderQuestionTrueOrFalse({\n question,\n result,\n });\n break;\n case QUESTION_TYPE.DISSERTATIVA:\n content = (\n <>\n {renderQuestionDissertative({\n result,\n })}\n {onQuestionCorrectionSubmit && (\n <div className=\"space-y-4 border-t border-border-100 pt-4 mt-4\">\n {renderEssayCorrectionFields(questionData)}\n </div>\n )}\n </>\n );\n break;\n case QUESTION_TYPE.PREENCHER_LACUNAS:\n content = renderQuestionFill({\n question,\n result,\n });\n break;\n case QUESTION_TYPE.IMAGEM:\n content = renderQuestionImage({\n result,\n });\n break;\n case QUESTION_TYPE.RELACIONAR:\n content = renderQuestionConnectDots({ paddingBottom: '' });\n break;\n default:\n // Fallback: try to render based on options presence\n if (question.options && question.options.length > 0) {\n content = renderQuestionAlternative({\n question,\n result,\n });\n } else {\n content = renderQuestionDissertative({\n result,\n });\n }\n }\n\n return (\n <CardAccordation\n value={`accordion-${questionData.questionNumber}`}\n className=\"border border-border-100 rounded-lg\"\n trigger={\n <div className=\"py-3 pr-2 w-full\">\n <Text className=\"text-sm font-bold text-text-950\">\n {accordionTitle}\n </Text>\n </div>\n }\n >\n {content}\n </CardAccordation>\n );\n };\n\n /**\n * Render essay correction fields (radio group, textarea, save button)\n */\n const renderEssayCorrectionFields = (\n questionData: StudentActivityCorrectionData['questions'][number]\n ) => {\n const correction = essayCorrections[questionData.questionNumber] || {\n isCorrect: null,\n teacherFeedback: '',\n isSaving: false,\n };\n\n let radioValue;\n if (correction.isCorrect === null) {\n radioValue = undefined;\n } else if (correction.isCorrect) {\n radioValue = 'true';\n } else {\n radioValue = 'false';\n }\n\n return (\n <>\n {/* Is correct radio group */}\n <div className=\"space-y-2\">\n <Text className=\"text-sm font-semibold text-text-950\">\n Resposta está correta?\n </Text>\n <div className=\"flex gap-4\">\n <Radio\n name={`isCorrect-${questionData.questionNumber}`}\n value=\"true\"\n id={`correct-yes-${questionData.questionNumber}`}\n label=\"Sim\"\n size=\"medium\"\n checked={radioValue === 'true'}\n onChange={(e) => {\n if (e.target.checked) {\n updateEssayCorrection(\n questionData.questionNumber,\n EssayCorrectionField.IsCorrect,\n true\n );\n }\n }}\n />\n <Radio\n name={`isCorrect-${questionData.questionNumber}`}\n value=\"false\"\n id={`correct-no-${questionData.questionNumber}`}\n label=\"Não\"\n size=\"medium\"\n checked={radioValue === 'false'}\n onChange={(e) => {\n if (e.target.checked) {\n updateEssayCorrection(\n questionData.questionNumber,\n EssayCorrectionField.IsCorrect,\n false\n );\n }\n }}\n />\n </div>\n </div>\n\n {/* Teacher feedback textarea */}\n <div className=\"space-y-2\">\n <Text className=\"text-sm font-semibold text-text-950\">\n Incluir observação\n </Text>\n <TextArea\n value={correction.teacherFeedback}\n onChange={(e) => {\n updateEssayCorrection(\n questionData.questionNumber,\n EssayCorrectionField.TeacherFeedback,\n e.target.value\n );\n }}\n placeholder=\"Escreva uma observação sobre a resposta do aluno\"\n rows={4}\n size=\"medium\"\n />\n </div>\n\n {/* Save button */}\n <Button\n size=\"small\"\n onClick={() => handleSaveEssayCorrection(questionData.questionNumber)}\n disabled={\n correction.isCorrect === null ||\n correction.isSaving ||\n !onQuestionCorrectionSubmit\n }\n >\n {correction.isSaving ? 'Salvando...' : 'Salvar'}\n </Button>\n </>\n );\n };\n\n if (!data) return null;\n\n const title = isViewOnly ? 'Detalhes da atividade' : 'Corrigir atividade';\n const formattedScore = data.score == null ? '-' : data.score.toFixed(1);\n\n /**\n * Render observation section based on current state\n * Allows observations in all activity statuses (not just pending correction)\n * @returns JSX element for observation section\n */\n const renderObservationSection = () => {\n /**\n * Get file name from URL\n * @param url - File URL\n * @returns File name extracted from URL\n */\n const getFileNameFromUrl = (_url: string): string => 'Anexado';\n\n /**\n * Render attachment input section for expanded state\n * @returns JSX element for attachment input\n */\n const renderAttachmentInput = () => {\n if (attachedFiles.length > 0) {\n return (\n <div className=\"flex items-center justify-center gap-2 px-5 h-10 bg-secondary-500 rounded-full min-w-0 max-w-[150px]\">\n <PaperclipIcon size={18} className=\"text-text-800 flex-shrink-0\" />\n <span className=\"text-base font-medium text-text-800 truncate\">\n {attachedFiles[0].file.name}\n </span>\n <button\n type=\"button\"\n onClick={() => handleFileRemove(attachedFiles[0].id)}\n className=\"text-text-700 hover:text-text-800 flex-shrink-0\"\n aria-label={`Remover ${attachedFiles[0].file.name}`}\n >\n <XIcon size={18} />\n </button>\n </div>\n );\n }\n\n if (existingAttachment) {\n return (\n <div className=\"flex items-center gap-2\">\n <a\n href={existingAttachment}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 px-5 h-10 bg-secondary-500 rounded-full min-w-0 max-w-[150px] hover:bg-secondary-600 transition-colors\"\n >\n <PaperclipIcon\n size={18}\n className=\"text-text-800 flex-shrink-0\"\n />\n <span className=\"text-base font-medium text-text-800 truncate\">\n {getFileNameFromUrl(existingAttachment)}\n </span>\n </a>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={() => fileInputRef.current?.click()}\n className=\"flex items-center gap-2\"\n >\n <PaperclipIcon size={18} />\n Trocar\n </Button>\n </div>\n );\n }\n\n return (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={() => fileInputRef.current?.click()}\n className=\"flex items-center gap-2\"\n >\n <PaperclipIcon size={18} />\n Anexar\n </Button>\n );\n };\n\n // State: Saved\n if (isObservationSaved) {\n return (\n <div className=\"bg-background border border-border-100 rounded-lg p-4 space-y-2\">\n <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\n <Text className=\"text-sm font-bold text-text-950\">Observação</Text>\n <div className=\"flex items-center gap-3\">\n {/* Show newly attached file */}\n {savedFiles.length > 0 && (\n <div className=\"flex items-center gap-2 px-5 h-10 bg-secondary-500 rounded-full min-w-0 max-w-[150px]\">\n <PaperclipIcon\n size={18}\n className=\"text-text-800 flex-shrink-0\"\n />\n <span className=\"text-base font-medium text-text-800 truncate\">\n {savedFiles[0].file.name}\n </span>\n </div>\n )}\n {/* Show existing attachment from server (URL) */}\n {savedFiles.length === 0 && existingAttachment && (\n <a\n href={existingAttachment}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 px-5 h-10 bg-secondary-500 rounded-full min-w-0 max-w-[150px] hover:bg-secondary-600 transition-colors\"\n >\n <PaperclipIcon\n size={18}\n className=\"text-text-800 flex-shrink-0\"\n />\n <span className=\"text-base font-medium text-text-800 truncate\">\n {getFileNameFromUrl(existingAttachment)}\n </span>\n </a>\n )}\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={handleEditObservation}\n className=\"flex items-center gap-2 flex-shrink-0\"\n >\n <PencilSimpleIcon size={16} />\n Editar\n </Button>\n </div>\n </div>\n {savedObservation && (\n <div className=\"p-3 bg-background-50 rounded-lg\">\n <Text className=\"text-sm text-text-700\">{savedObservation}</Text>\n </div>\n )}\n </div>\n );\n }\n\n // State: Expanded\n if (isObservationExpanded) {\n return (\n <div className=\"bg-background border border-border-100 rounded-lg p-4 space-y-3\">\n <Text className=\"text-sm font-bold text-text-950\">Observação</Text>\n <textarea\n value={observation}\n onChange={(e) => setObservation(e.target.value)}\n placeholder=\"Escreva uma observação para o estudante\"\n className=\"w-full min-h-[80px] p-3 border border-border-100 rounded-lg text-sm text-text-700 placeholder:text-text-400 resize-none focus:outline-none focus:ring-2 focus:ring-primary-500\"\n />\n {/* Hidden file input */}\n <input\n type=\"file\"\n ref={fileInputRef}\n className=\"hidden\"\n onChange={(e) => {\n const selectedFiles = e.target.files;\n if (selectedFiles && selectedFiles.length > 0) {\n handleFilesAdd(Array.from(selectedFiles));\n }\n if (fileInputRef.current) {\n fileInputRef.current.value = '';\n }\n }}\n aria-label=\"Selecionar arquivo\"\n />\n {/* Buttons row: File indicator or Anexar button left, Salvar right */}\n <div className=\"flex flex-col-reverse sm:flex-row gap-3 sm:justify-between\">\n {renderAttachmentInput()}\n <Button\n type=\"button\"\n size=\"small\"\n onClick={handleSaveObservation}\n disabled={\n !observation.trim() &&\n attachedFiles.length === 0 &&\n !existingAttachment\n }\n >\n Salvar\n </Button>\n </div>\n {data.observation && (\n <div className=\"p-3 bg-background-50 rounded-lg\">\n <Text className=\"text-xs text-text-500\">\n Observação anterior:\n </Text>\n <Text className=\"text-sm text-text-700\">{data.observation}</Text>\n </div>\n )}\n </div>\n );\n }\n\n // State: Closed (default)\n return (\n <div className=\"bg-background border border-border-100 rounded-lg p-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between\">\n <Text className=\"text-sm font-bold text-text-950\">Observação</Text>\n <Button type=\"button\" size=\"small\" onClick={handleOpenObservation}>\n Incluir\n </Button>\n </div>\n );\n };\n\n return (\n <Modal\n isOpen={isOpen}\n onClose={onClose}\n title={title}\n size=\"lg\"\n contentClassName=\"max-h-[80vh] overflow-y-auto\"\n >\n <div className=\"space-y-6\">\n {/* Student Info */}\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-3\">\n <div className=\"w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center\">\n <Text className=\"text-lg font-semibold text-primary-700\">\n {data.studentName?.charAt(0).toUpperCase() || '-'}\n </Text>\n </div>\n <Text className=\"text-lg font-medium text-text-950\">\n {data.studentName || 'Aluno'}\n </Text>\n </div>\n\n {/* View scanned answer sheet button */}\n {answerSheetImageUrl && onViewScannedAnswerSheet && (\n <Button\n variant=\"outline\"\n size=\"small\"\n onClick={onViewScannedAnswerSheet}\n >\n <ImageIcon size={18} className=\"mr-2\" />\n Ver gabarito escaneado\n </Button>\n )}\n </div>\n\n {/* Stats Cards */}\n <div className=\"grid grid-cols-3 gap-4\">\n <StatCard label=\"Nota\" value={formattedScore} variant=\"score\" />\n <StatCard\n label=\"N° de questões corretas\"\n value={data.correctCount}\n variant=\"correct\"\n />\n <StatCard\n label=\"N° de questões incorretas\"\n value={data.incorrectCount}\n variant=\"incorrect\"\n />\n </div>\n\n {/* Observation Section */}\n {renderObservationSection()}\n\n {/* Questions List */}\n <div className=\"space-y-2\">\n <Text className=\"text-sm font-bold text-text-950\">Respostas</Text>\n <AccordionGroup type=\"multiple\" className=\"space-y-2\">\n {data.questions?.map((questionData) => {\n // Check if we have a local correction for essay questions\n const localCorrection =\n essayCorrections[questionData.questionNumber];\n const isEssayWithLocalCorrection =\n questionData.question.questionType ===\n QUESTION_TYPE.DISSERTATIVA &&\n localCorrection?.isSaved &&\n localCorrection?.isCorrect != null;\n\n // Use local correction status for essay questions, otherwise use original\n let badgeConfig;\n if (isEssayWithLocalCorrection) {\n const localStatus = localCorrection.isCorrect\n ? QUESTION_STATUS.CORRETA\n : QUESTION_STATUS.INCORRETA;\n badgeConfig = getQuestionStatusBadgeConfig(localStatus);\n } else {\n const status = getQuestionStatusFromData(questionData);\n badgeConfig = getQuestionStatusBadgeConfig(status);\n }\n\n return (\n <CardAccordation\n key={questionData.questionNumber}\n value={`question-${questionData.questionNumber}`}\n className=\"bg-background rounded-xl\"\n trigger={\n <div className=\"flex items-center justify-between w-full py-3 pr-2\">\n <Text className=\"text-base font-bold text-text-950\">\n Questão {questionData.questionNumber}\n </Text>\n <Badge\n className={cn(\n 'text-xs px-2 py-1',\n badgeConfig.bgColor,\n badgeConfig.textColor\n )}\n >\n {badgeConfig.label}\n </Badge>\n </div>\n }\n >\n <div className=\"space-y-4 pt-2\">\n {/* Question statement */}\n {questionData.question.statement && (\n <HtmlMathRenderer\n content={questionData.question.statement}\n className=\"text-sm text-text-700\"\n />\n )}\n\n {/* Question content based on type */}\n {renderQuestionContent(questionData)}\n </div>\n </CardAccordation>\n );\n })}\n </AccordionGroup>\n </div>\n </div>\n </Modal>\n );\n};\n\nexport default CorrectActivityModal;\n","/**\n * Question status enum for student activity correction\n * Maps from ANSWER_STATUS to a simpler status for UI display\n */\nexport const QUESTION_STATUS = {\n CORRETA: 'CORRETA',\n INCORRETA: 'INCORRETA',\n EM_BRANCO: 'EM_BRANCO',\n /** Reserved for future use - pending teacher evaluation for essay questions */\n PENDENTE: 'PENDENTE',\n} as const;\n\nexport type QuestionStatus =\n (typeof QUESTION_STATUS)[keyof typeof QUESTION_STATUS];\n","import { StatusBadgeConfig } from '@/types/activityDetails';\nimport {\n ANSWER_STATUS,\n QUESTION_TYPE,\n} from '../../components/Quiz/useQuizStore';\nimport { QUESTION_STATUS, type QuestionStatus } from './constants';\nimport type { CorrectionQuestionData } from './types';\n\n/**\n * Returns whether the answer is correct, incorrect, or null based on the answer status.\n * @param answerStatus - Answer status from ANSWER_STATUS enum\n * @returns Returns true for correct, false for incorrect, or null if undefined.\n */\nexport const getIsCorrect = (answerStatus: ANSWER_STATUS): boolean | null => {\n if (answerStatus === ANSWER_STATUS.RESPOSTA_CORRETA) return true;\n if (answerStatus === ANSWER_STATUS.RESPOSTA_INCORRETA) return false;\n return null;\n};\n\n/**\n * Map ANSWER_STATUS from Quiz to QUESTION_STATUS for correction modal\n * @param answerStatus - Answer status from QuestionResult\n * @returns QuestionStatus for UI display\n */\nexport const mapAnswerStatusToQuestionStatus = (\n answerStatus: ANSWER_STATUS\n): QuestionStatus => {\n switch (answerStatus) {\n case ANSWER_STATUS.RESPOSTA_CORRETA:\n return QUESTION_STATUS.CORRETA;\n case ANSWER_STATUS.RESPOSTA_INCORRETA:\n return QUESTION_STATUS.INCORRETA;\n case ANSWER_STATUS.NAO_RESPONDIDO:\n return QUESTION_STATUS.EM_BRANCO;\n case ANSWER_STATUS.PENDENTE_AVALIACAO:\n return QUESTION_STATUS.PENDENTE;\n default:\n return QUESTION_STATUS.EM_BRANCO;\n }\n};\n\n/**\n * Get question status badge configuration\n * @param status - Question status\n * @returns Badge configuration with label and colors\n */\nexport const getQuestionStatusBadgeConfig = (status: QuestionStatus) => {\n const configs: Partial<Record<QuestionStatus, StatusBadgeConfig>> = {\n [QUESTION_STATUS.CORRETA]: {\n label: 'Correta',\n bgColor: 'bg-success-background',\n textColor: 'text-success-800',\n },\n [QUESTION_STATUS.INCORRETA]: {\n label: 'Incorreta',\n bgColor: 'bg-error-background',\n textColor: 'text-error-800',\n },\n [QUESTION_STATUS.EM_BRANCO]: {\n label: 'Em branco',\n bgColor: 'bg-gray-100',\n textColor: 'text-gray-600',\n },\n [QUESTION_STATUS.PENDENTE]: {\n label: 'Pendente',\n bgColor: 'bg-warning-background',\n textColor: 'text-warning-800',\n },\n };\n\n const defaultConfig = {\n label: 'Sem categoria',\n bgColor: 'bg-gray-100',\n textColor: 'text-gray-600',\n };\n\n return configs[status] ?? defaultConfig;\n};\n\n/**\n * Check if question can be auto-validated based on options\n * All question types can be auto-validated except DISSERTATIVA (essay questions)\n * @param questionData - Correction question data\n * @returns true if can auto-validate, false otherwise\n */\nexport const canAutoValidate = (\n questionData: CorrectionQuestionData\n): boolean => {\n const { result } = questionData;\n\n // Only dissertative questions require manual evaluation\n if (result.questionType === QUESTION_TYPE.DISSERTATIVA) {\n return false;\n }\n\n return true;\n};\n\ntype Option = {\n id: string;\n option: string;\n isCorrect: boolean;\n};\n\n/**\n * Type predicate to check if an option has isCorrect defined\n */\nconst hasIsCorrect = (op: {\n id: string;\n option: string;\n isCorrect?: boolean | null;\n}): op is Option => {\n return op.isCorrect != null;\n};\n\n/**\n * Validate alternativa (single choice) question\n * Must select exactly one correct option\n */\nconst validateAlternativa = (\n selected: Set<string>,\n options: Option[]\n): ANSWER_STATUS => {\n if (selected.size !== 1) return ANSWER_STATUS.RESPOSTA_INCORRETA;\n\n const [selectedId] = selected;\n return options.find((o) => o.id === selectedId)?.isCorrect\n ? ANSWER_STATUS.RESPOSTA_CORRETA\n : ANSWER_STATUS.RESPOSTA_INCORRETA;\n};\n\n/**\n * Validate multipla escolha (multiple choice) question\n * Each option must match its correct state: selected if correct, not selected if incorrect\n */\nconst validateMultiplaEscolha = (\n selected: Set<string>,\n options: Option[]\n): ANSWER_STATUS => {\n const allMatch = options.every((op) => selected.has(op.id) === op.isCorrect);\n\n return allMatch\n ? ANSWER_STATUS.RESPOSTA_CORRETA\n : ANSWER_STATUS.RESPOSTA_INCORRETA;\n};\n\n/**\n * Validate verdadeiro/falso (true/false) question\n * Each statement is evaluated individually: if statement is true, must be selected;\n * if statement is false, must not be selected\n */\nconst validateVerdadeiroFalso = validateMultiplaEscolha;\n\nconst validators: Partial<\n Record<QUESTION_TYPE, (s: Set<string>, o: Option[]) => ANSWER_STATUS>\n> = {\n [QUESTION_TYPE.ALTERNATIVA]: validateAlternativa,\n [QUESTION_TYPE.MULTIPLA_ESCOLHA]: validateMultiplaEscolha,\n [QUESTION_TYPE.VERDADEIRO_FALSO]: validateVerdadeiroFalso,\n};\n\n/**\n * Auto-validate question based on selected options vs correct options\n * @param questionData - Correction question data\n * @returns ANSWER_STATUS if can determine, null otherwise\n */\nexport const autoValidateQuestion = (\n questionData: CorrectionQuestionData\n): ANSWER_STATUS | null => {\n const { result } = questionData;\n\n if (!canAutoValidate(questionData) || !result.options) return null;\n\n // Filter options to only include those with isCorrect defined\n const validOptions = result.options.filter(hasIsCorrect);\n if (validOptions.length === 0) return null;\n\n const selected = new Set(\n result.selectedOptions?.map((o) => o.optionId) ?? []\n );\n\n if (selected.size === 0) return ANSWER_STATUS.NAO_RESPONDIDO;\n\n const validator = validators[result.questionType];\n if (!validator) return null;\n\n return validator(selected, validOptions);\n};\n\n/**\n * Get question status from CorrectionQuestionData\n * Maps the result's answerStatus to QuestionStatus\n * If status is PENDENTE_AVALIACAO but can auto-validate, calculates status automatically\n * @param questionData - Correction question data\n * @returns QuestionStatus for UI display\n */\nexport const getQuestionStatusFromData = (\n questionData: CorrectionQuestionData\n): QuestionStatus => {\n const { result } = questionData;\n\n // If pending evaluation, try to auto-validate\n if (\n result.answerStatus === ANSWER_STATUS.PENDENTE_AVALIACAO &&\n canAutoValidate(questionData)\n ) {\n const autoValidatedStatus = autoValidateQuestion(questionData);\n if (autoValidatedStatus !== null) {\n return mapAnswerStatusToQuestionStatus(autoValidatedStatus);\n }\n }\n\n return mapAnswerStatusToQuestionStatus(result.answerStatus);\n};\n","import type { Question } from '../../components/Quiz/useQuizStore';\nimport { QUESTION_TYPE } from '../../components/Quiz/useQuizStore';\nimport type {\n QuestionsAnswersByStudentResponse,\n StudentActivityCorrectionData,\n CorrectionQuestionData,\n EssayQuestionCorrection,\n} from './types';\nimport { getIsCorrect } from './utils';\n\n/**\n * Build Question object from answer data\n * @param answer - Answer data from QuestionResult\n * @returns Question object in Quiz format\n */\nconst buildQuestionFromAnswer = (\n answer: QuestionsAnswersByStudentResponse['data']['answers'][number]\n): Question => {\n return {\n id: answer.questionId,\n statement: answer.statement,\n questionType: answer.questionType,\n difficultyLevel: answer.difficultyLevel,\n description: '',\n examBoard: null,\n examYear: null,\n solutionExplanation: answer.solutionExplanation || null,\n additionalContent: answer.additionalContent || null,\n answer: null,\n answerStatus: answer.answerStatus,\n options: answer.options || [],\n knowledgeMatrix: answer.knowledgeMatrix.map((matrix) => ({\n areaKnowledge: matrix.areaKnowledge || {\n id: '',\n name: '',\n },\n subject: matrix.subject || {\n id: '',\n name: '',\n color: '',\n icon: '',\n },\n topic: matrix.topic || {\n id: '',\n name: '',\n },\n subtopic: matrix.subtopic || {\n id: '',\n name: '',\n },\n content: matrix.content || {\n id: '',\n name: '',\n },\n })),\n correctOptionIds:\n answer.options?.filter((opt) => opt.isCorrect).map((opt) => opt.id) || [],\n };\n};\n\n/**\n * Build correction data for essay questions\n * @param answer - Answer data from QuestionResult\n * @returns EssayQuestionCorrection if question is DISSERTATIVA, undefined otherwise\n */\nconst buildEssayCorrection = (\n answer: QuestionsAnswersByStudentResponse['data']['answers'][number]\n): EssayQuestionCorrection | undefined => {\n if (answer.questionType === QUESTION_TYPE.DISSERTATIVA) {\n return {\n isCorrect: getIsCorrect(answer.answerStatus),\n teacherFeedback: answer.teacherFeedback || '',\n };\n }\n return undefined;\n};\n\n/**\n * Convert a single answer to CorrectionQuestionData format\n * @param answer - Answer data from QuestionResult\n * @param index - Index of the answer (0-based)\n * @returns CorrectionQuestionData formatted for the modal\n */\nconst convertAnswerToQuestionData = (\n answer: QuestionsAnswersByStudentResponse['data']['answers'][number],\n index: number\n): CorrectionQuestionData => {\n const question = buildQuestionFromAnswer(answer);\n const correction = buildEssayCorrection(answer);\n\n return {\n question,\n result: answer,\n questionNumber: index + 1,\n correction,\n };\n};\n\n/**\n * Convert QuestionResult from API to StudentActivityCorrectionData\n * This function transforms the API response into the format expected by CorrectActivityModal\n * @param apiResponse - API response with answers and statistics\n * @param studentId - Student ID\n * @param studentName - Student name\n * @param observation - Optional teacher observation\n * @param attachment - Optional attachment URL\n * @returns StudentActivityCorrectionData formatted for the modal\n */\nexport const convertApiResponseToCorrectionData = (\n apiResponse: QuestionsAnswersByStudentResponse,\n studentId: string,\n studentName: string,\n observation?: string,\n attachment?: string\n): StudentActivityCorrectionData => {\n const { answers, statistics } = apiResponse.data;\n\n // Calculate counts from statistics\n const correctCount = statistics.correctAnswers;\n const incorrectCount = statistics.incorrectAnswers;\n const blankCount = statistics.totalAnswered - correctCount - incorrectCount;\n\n // Convert answers to CorrectionQuestionData format\n const questions: CorrectionQuestionData[] = answers.map((answer, index) =>\n convertAnswerToQuestionData(answer, index)\n );\n\n return {\n studentId,\n studentName,\n score: statistics.score ?? null,\n correctCount,\n incorrectCount,\n blankCount,\n questions,\n observation,\n attachment,\n };\n};\n","import type { ReactNode } from 'react';\nimport { ANSWER_STATUS } from '../../../components/Quiz/useQuizStore';\nimport { AlternativesList } from '../../../components/Alternative/Alternative';\nimport { OptionStatus } from '../../../enums/Options';\nimport Text from '../../../components/Text/Text';\nimport type { QuestionRendererProps } from '../types';\n\n/**\n * Render alternative question (single choice)\n * Returns content without wrapper (for accordion use)\n */\nexport const renderQuestionAlternative = ({\n question,\n result,\n}: QuestionRendererProps): ReactNode => {\n // Check if options have isCorrect defined (can auto-validate even if pending)\n const hasAutoValidation = result?.options?.some(\n (op) => op.isCorrect !== undefined && op.isCorrect !== null\n );\n\n const alternatives = question.options?.map((option) => {\n const isCorrectOption =\n result?.options?.find((op) => op.id === option.id)?.isCorrect || false;\n\n const isSelected =\n result?.selectedOptions?.some(\n (selectedOption) => selectedOption.optionId === option.id\n ) || false;\n\n // Show correct answers if not pending, OR if we can auto-validate (has isCorrect)\n const shouldShowCorrectAnswers =\n result?.answerStatus !== ANSWER_STATUS.PENDENTE_AVALIACAO ||\n hasAutoValidation;\n\n let status: OptionStatus;\n if (shouldShowCorrectAnswers) {\n if (isCorrectOption) {\n status = OptionStatus.CORRECT;\n } else if (isSelected && !isCorrectOption) {\n status = OptionStatus.INCORRECT;\n } else {\n status = OptionStatus.NEUTRAL;\n }\n } else {\n // When pending evaluation and no auto-validation, show all options as neutral\n status = OptionStatus.NEUTRAL;\n }\n\n return {\n label: option.option,\n value: option.id,\n status: status,\n };\n });\n\n if (!alternatives || alternatives.length === 0) {\n return (\n <div>\n <Text size=\"sm\" weight=\"normal\">\n Não há Alternativas\n </Text>\n </div>\n );\n }\n\n return (\n <div className=\"pt-2\">\n <AlternativesList\n mode=\"readonly\"\n key={`question-${question.id}`}\n name={`question-${question.id}`}\n layout=\"compact\"\n alternatives={alternatives}\n selectedValue={result?.selectedOptions?.[0]?.optionId || ''}\n />\n </div>\n );\n};\n","import type { ReactNode } from 'react';\nimport { ANSWER_STATUS } from '../../../components/Quiz/useQuizStore';\nimport { MultipleChoiceList } from '../../../components/MultipleChoice/MultipleChoice';\nimport { OptionStatus } from '../../../enums/Options';\nimport Text from '../../../components/Text/Text';\nimport type { QuestionRendererProps } from '../types';\n\n/**\n * Render multiple choice question\n * Returns content without wrapper (for accordion use)\n */\nexport const renderQuestionMultipleChoice = ({\n question,\n result,\n}: QuestionRendererProps): ReactNode => {\n // Check if options have isCorrect defined (can auto-validate even if pending)\n const hasAutoValidation = result?.options?.some(\n (op) => op.isCorrect !== undefined && op.isCorrect !== null\n );\n\n const choices = question.options?.map((option) => {\n const isCorrectOption =\n result?.options?.find((op) => op.id === option.id)?.isCorrect || false;\n\n const isSelected = result?.selectedOptions?.some(\n (op) => op.optionId === option.id\n );\n\n // Show correct/incorrect status if not pending/blank, OR if we can auto-validate (has isCorrect)\n const shouldShowCorrectAnswers =\n (result?.answerStatus !== ANSWER_STATUS.PENDENTE_AVALIACAO &&\n result?.answerStatus !== ANSWER_STATUS.NAO_RESPONDIDO) ||\n hasAutoValidation;\n\n let status: OptionStatus;\n if (shouldShowCorrectAnswers) {\n if (isCorrectOption) {\n status = OptionStatus.CORRECT;\n } else if (isSelected && !isCorrectOption) {\n status = OptionStatus.INCORRECT;\n } else {\n status = OptionStatus.NEUTRAL;\n }\n } else {\n // When pending evaluation and no auto-validation, show all options as neutral\n status = OptionStatus.NEUTRAL;\n }\n\n return {\n label: option.option,\n value: option.id,\n status,\n };\n });\n\n if (!choices || choices.length === 0) {\n return (\n <div>\n <Text size=\"sm\" weight=\"normal\">\n Não há Caixas de seleção\n </Text>\n </div>\n );\n }\n\n const selectedValues =\n result?.selectedOptions?.map((op) => op.optionId) || [];\n\n return (\n <div className=\"pt-2\">\n <MultipleChoiceList\n mode=\"readonly\"\n key={`question-${question.id}`}\n name={`question-${question.id}`}\n choices={choices}\n selectedValues={selectedValues}\n />\n </div>\n );\n};\n","import { Fragment, useId, type ReactNode } from 'react';\nimport Badge from '../../../components/Badge/Badge';\nimport { CheckCircleIcon } from '@phosphor-icons/react/dist/csr/CheckCircle';\nimport { XCircleIcon } from '@phosphor-icons/react/dist/csr/XCircle';\nimport { cn } from '../../utils';\nimport Text from '../../../components/Text/Text';\nimport type { QuestionRendererProps } from '../types';\nimport { stripHtmlTags } from '../../stringUtils';\nimport { OptionStatus } from '../../../enums/Options';\n\n/**\n * Get status badge component\n */\nexport const getStatusBadge = (status?: OptionStatus): ReactNode => {\n switch (status) {\n case OptionStatus.CORRECT:\n return (\n <Badge variant=\"solid\" action=\"success\" iconLeft={<CheckCircleIcon />}>\n Resposta correta\n </Badge>\n );\n case OptionStatus.INCORRECT:\n return (\n <Badge variant=\"solid\" action=\"error\" iconLeft={<XCircleIcon />}>\n Resposta incorreta\n </Badge>\n );\n default:\n return null;\n }\n};\n\n/**\n * Container component for question content\n */\nexport const QuestionContainer = ({\n children,\n className,\n}: {\n children: ReactNode;\n className?: string;\n}) => {\n return (\n <div\n className={cn(\n 'bg-background rounded-t-xl px-4 pt-4 pb-[80px] h-auto flex flex-col gap-4 mb-auto',\n className\n )}\n >\n {children}\n </div>\n );\n};\n\n/**\n * Subtitle component for question sections\n */\nexport const QuestionSubTitle = ({ subTitle }: { subTitle: string }) => {\n return (\n <div className=\"px-4 pb-2 pt-6\">\n <Text size=\"md\" weight=\"bold\" color=\"text-text-950\">\n {subTitle}\n </Text>\n </div>\n );\n};\n\n/**\n * Internal component for fill in the blanks question\n * Uses useId hook to generate unique IDs\n *\n * Data structure:\n * - additionalContent: HTML text with {placeholderId} placeholders\n * - fillAnswers: Record<placeholderId, selectedOptionId> (what student chose)\n * - options: Array<{ id, option }> where id is placeholderId AND correct optionId\n * - Correctness: selectedOptionId === placeholderId means correct\n */\nexport const FillQuestionContent = ({\n question,\n result,\n}: QuestionRendererProps) => {\n const baseId = useId();\n\n // Get additionalContent (contains HTML with {placeholderId} placeholders)\n // Falls back to question.statement for older questions that may not have additionalContent\n const additionalContent =\n result?.additionalContent ||\n question.additionalContent ||\n question.statement ||\n '';\n\n // Strip HTML tags for clean text rendering\n const cleanText = stripHtmlTags(additionalContent);\n\n // Get options array (id is placeholderId, option is the text)\n const options =\n (\n result as {\n options?: Array<{ id: string; option: string; isCorrect: boolean }>;\n }\n )?.options ||\n question.options ||\n [];\n\n // Build a map of optionId -> option text for quick lookup\n const optionTextMap: Record<string, string> = {};\n options.forEach((opt) => {\n