867 lines
36 KiB
TypeScript
867 lines
36 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import type { Exam, ExamSubmission } from '../types';
|
|
import {
|
|
ClipboardList, Clock, ChevronLeft, ChevronRight, Send, CheckCircle2,
|
|
XCircle, Award, AlertTriangle, Timer, ArrowLeft, RefreshCw, Lock, Unlock
|
|
} from 'lucide-react';
|
|
import { normalizePhotoUrl } from '../helpers';
|
|
|
|
// ==========================================
|
|
// Exam Environment — Portal do Aluno
|
|
// ==========================================
|
|
|
|
type ExamView = 'listing' | 'exam' | 'result';
|
|
|
|
interface ExamResult {
|
|
total_questions: number;
|
|
correct_count: number;
|
|
wrong_count: number;
|
|
percentage: number;
|
|
final_score: number;
|
|
}
|
|
|
|
export default function Avaliacoes() {
|
|
const { token } = useAuth();
|
|
const [exams, setExams] = useState<Exam[]>([]);
|
|
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'exams' | 'activities'>('exams');
|
|
|
|
// Exam mode state
|
|
const [view, setView] = useState<ExamView>('listing');
|
|
const [activeExam, setActiveExam] = useState<Exam | null>(null);
|
|
const [currentQ, setCurrentQ] = useState(0);
|
|
const [answers, setAnswers] = useState<Record<string, number>>({});
|
|
const [timeLeft, setTimeLeft] = useState(0);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [result, setResult] = useState<ExamResult | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// In-app modal state (replaces native alert/confirm)
|
|
const [modalMsg, setModalMsg] = useState('');
|
|
const [modalType, setModalType] = useState<'info' | 'error' | 'confirm' | 'loading'>('info');
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [confirmCallback, setConfirmCallback] = useState<(() => void) | null>(null);
|
|
|
|
const showAppAlert = (msg: string, type: 'info' | 'error' = 'info') => {
|
|
setModalMsg(msg);
|
|
setModalType(type);
|
|
setConfirmCallback(null);
|
|
setShowModal(true);
|
|
};
|
|
|
|
const showAppConfirm = (msg: string, onConfirm: () => void) => {
|
|
setModalMsg(msg);
|
|
setModalType('confirm');
|
|
setConfirmCallback(() => onConfirm);
|
|
setShowModal(true);
|
|
};
|
|
|
|
// Fetch exams
|
|
const fetchExams = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await fetch('/api/portal/avaliacoes', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
const data = await res.json();
|
|
setExams(data.exams || []);
|
|
setSubmissions(data.submissions || []);
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
fetchExams();
|
|
}, [fetchExams]);
|
|
|
|
// Timer logic
|
|
useEffect(() => {
|
|
if (view !== 'exam' || timeLeft <= 0) return;
|
|
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft(prev => {
|
|
if (prev <= 1) {
|
|
// Auto-submit when time runs out
|
|
clearInterval(timerRef.current!);
|
|
handleSubmit(true);
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
};
|
|
}, [view, timeLeft > 0]);
|
|
|
|
const formatTimer = (seconds: number) => {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// Start exam
|
|
const startExam = (exam: Exam) => {
|
|
setActiveExam(exam);
|
|
setCurrentQ(0);
|
|
setAnswers({});
|
|
setTimeLeft(exam.durationMinutes * 60);
|
|
setResult(null);
|
|
setView('exam');
|
|
};
|
|
|
|
// Submit exam
|
|
const handleSubmit = async (autoSubmit = false) => {
|
|
if (submitting || !activeExam) return;
|
|
setSubmitting(true);
|
|
|
|
const typeLabel = (activeExam as any).evaluationType === 'activity' ? 'atividade' : 'prova';
|
|
|
|
// Show Loading Modal
|
|
setModalType('loading');
|
|
setModalMsg(`Enviando sua ${typeLabel}... Por favor, aguarde e não feche esta janela.`);
|
|
setShowModal(true);
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
|
|
try {
|
|
// Artificial delay of 5 seconds to let the student read the message
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
|
|
const res = await fetch('/api/portal/avaliacoes/submeter', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ examId: activeExam.id, answers }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
// Show Success Modal
|
|
setModalType('info');
|
|
setModalMsg(`Sua ${typeLabel} foi enviada com sucesso! Clique em OK para ver seu resultado.`);
|
|
setShowModal(true);
|
|
|
|
setConfirmCallback(() => {
|
|
setResult(data.result);
|
|
setView('result');
|
|
fetchExams();
|
|
});
|
|
} else {
|
|
const errorCode = `ERR-${activeExam.id.substring(0, 4)}-${new Date().getTime().toString().slice(-4)}`;
|
|
// Use the error message from server if available
|
|
showAppAlert(data.error || `Não foi possível enviar sua nota. Tente novamente ou contate o suporte. (Código: ${errorCode})`, 'error');
|
|
if (!autoSubmit) setView('listing');
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
const errorCode = `CONN-ERR-${new Date().getTime().toString().slice(-4)}`;
|
|
showAppAlert(`Erro de conexão ao enviar prova. Verifique sua internet. (Código: ${errorCode})`, 'error');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const selectAnswer = (questionId: string, optionIndex: number) => {
|
|
setAnswers(prev => ({ ...prev, [questionId]: optionIndex }));
|
|
};
|
|
|
|
const getSubmission = (examId: string) =>
|
|
submissions.find(s => s.exam_id === examId);
|
|
|
|
const renderAppModal = () => {
|
|
if (!showModal) return null;
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, zIndex: 99999,
|
|
background: 'rgba(0,0,0,0.6)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
padding: '1rem',
|
|
}}>
|
|
<div className="glass-card animate-scale-in" style={{
|
|
maxWidth: 400, width: '100%', padding: '2rem', textAlign: 'center',
|
|
background: 'var(--color-surface)',
|
|
}}>
|
|
<div style={{
|
|
width: 56, height: 56, borderRadius: '50%', margin: '0 auto 1rem',
|
|
background: modalType === 'error' ? 'var(--bg-danger-alpha)' : modalType === 'confirm' ? 'var(--bg-warning-alpha)' : 'var(--bg-primary-alpha)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
{modalType === 'error'
|
|
? <XCircle size={28} color="var(--color-danger)" />
|
|
: modalType === 'confirm'
|
|
? <AlertTriangle size={28} color="var(--color-warning)" />
|
|
: modalType === 'loading'
|
|
? <RefreshCw size={28} color="var(--color-primary)" className="animate-spin" />
|
|
: <CheckCircle2 size={28} color="var(--color-primary)" />
|
|
}
|
|
</div>
|
|
<p style={{ fontSize: '0.9rem', fontWeight: 500, marginBottom: modalType === 'loading' ? 0 : '1.5rem', lineHeight: 1.5 }}>
|
|
{modalMsg}
|
|
</p>
|
|
{modalType !== 'loading' && (
|
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
|
{modalType === 'confirm' ? (
|
|
<>
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
style={{
|
|
flex: 1, padding: '0.65rem', borderRadius: 10,
|
|
border: '1px solid var(--glass-border)',
|
|
background: 'var(--color-surface-light)',
|
|
color: 'var(--color-text)', fontWeight: 600,
|
|
cursor: 'pointer', fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowModal(false); confirmCallback?.(); }}
|
|
style={{
|
|
flex: 1, padding: '0.65rem', borderRadius: 10,
|
|
border: 'none',
|
|
background: 'var(--color-success)',
|
|
color: 'white', fontWeight: 700,
|
|
cursor: 'pointer', fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
Sim, Confirmar
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
style={{
|
|
width: '100%', padding: '0.65rem', borderRadius: 10,
|
|
border: 'none',
|
|
background: 'var(--color-primary)',
|
|
color: 'white', fontWeight: 700,
|
|
cursor: 'pointer', fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
OK
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ==========================================
|
|
// RENDER: Listing
|
|
// ==========================================
|
|
if (loading) {
|
|
return (
|
|
<div className="page-container">
|
|
<div className="skeleton" style={{ width: 250, height: 32, marginBottom: 24 }} />
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1rem' }}>
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="skeleton" style={{ height: 200, borderRadius: 16 }} />
|
|
))}
|
|
</div>
|
|
{renderAppModal()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// RENDER: Exam Mode (Focused)
|
|
// ==========================================
|
|
if (view === 'exam' && activeExam) {
|
|
const questions = activeExam.questions || [];
|
|
const question = questions[currentQ];
|
|
const totalQ = questions.length;
|
|
const answeredCount = Object.keys(answers).length;
|
|
const isUrgent = timeLeft <= 60;
|
|
const progress = totalQ > 0 ? ((currentQ + 1) / totalQ) * 100 : 0;
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, zIndex: 9999,
|
|
background: 'var(--color-surface)',
|
|
display: 'flex', flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{/* Exam Header */}
|
|
<div style={{
|
|
padding: '1rem 1.5rem',
|
|
background: 'var(--glass-bg)',
|
|
borderBottom: '1px solid var(--glass-border)',
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
backdropFilter: 'blur(12px)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<ClipboardList size={24} color="var(--color-primary)" />
|
|
<div>
|
|
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>{activeExam.title}</h2>
|
|
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
|
|
Questão {currentQ + 1} de {totalQ} • {answeredCount}/{totalQ} respondidas
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timer */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
padding: '0.5rem 1rem', borderRadius: 12,
|
|
background: isUrgent ? 'rgba(239, 68, 68, 0.15)' : 'var(--bg-primary-alpha)',
|
|
border: `1px solid ${isUrgent ? 'var(--color-danger)' : 'var(--color-primary-alpha)'}`,
|
|
animation: isUrgent ? 'pulse 1s infinite' : undefined,
|
|
}}>
|
|
<Timer size={18} color={isUrgent ? 'var(--color-danger)' : 'var(--color-primary)'} />
|
|
<span style={{
|
|
fontSize: '1.25rem', fontWeight: 800, fontFamily: 'monospace',
|
|
color: isUrgent ? 'var(--color-danger)' : 'var(--color-text)',
|
|
}}>
|
|
{formatTimer(timeLeft)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div style={{ height: 4, background: 'var(--glass-border)' }}>
|
|
<div style={{
|
|
height: '100%', width: `${progress}%`,
|
|
background: 'var(--gradient-primary)',
|
|
transition: 'width 0.3s ease',
|
|
}} />
|
|
</div>
|
|
|
|
{/* Question Area */}
|
|
<div style={{
|
|
flex: 1, overflow: 'auto',
|
|
display: 'flex', justifyContent: 'center', alignItems: 'flex-start',
|
|
padding: '2rem 1.5rem',
|
|
}}>
|
|
<div style={{ maxWidth: 700, width: '100%' }}>
|
|
{question && (
|
|
<div className="animate-fade-in" key={question.id}>
|
|
{/* Question Text & Image */}
|
|
<div className="glass-card" style={{
|
|
padding: '2rem', marginBottom: '1.5rem',
|
|
borderLeft: '4px solid var(--color-primary)',
|
|
}}>
|
|
<span style={{
|
|
display: 'inline-block', fontSize: '0.7rem', fontWeight: 700,
|
|
background: 'var(--bg-primary-alpha)', color: 'var(--color-primary)',
|
|
padding: '2px 10px', borderRadius: 20, marginBottom: '1rem',
|
|
}}>
|
|
QUESTÃO {currentQ + 1}
|
|
</span>
|
|
<p style={{ fontSize: '1.05rem', fontWeight: 500, lineHeight: 1.6, marginBottom: question.imageUrl ? '1.5rem' : 0 }}>
|
|
{question.text}
|
|
</p>
|
|
|
|
{question.imageUrl && (
|
|
<div style={{
|
|
marginTop: '1.5rem',
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
border: '2px solid var(--glass-border)',
|
|
background: 'white',
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
|
}}>
|
|
<div style={{ padding: '8px 12px', background: 'var(--bg-primary-alpha)', borderBottom: '1px solid var(--glass-border)', fontSize: '0.65rem', fontWeight: 800, color: 'var(--color-primary)', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
|
Imagem de Apoio
|
|
</div>
|
|
<img
|
|
src={normalizePhotoUrl(question.imageUrl)}
|
|
alt="Imagem de apoio"
|
|
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'zoom-in' }}
|
|
onClick={() => window.open(normalizePhotoUrl(question.imageUrl), '_blank')}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{question.options.map((opt, idx) => {
|
|
const isSelected = answers[question.id] === idx;
|
|
const letter = String.fromCharCode(65 + idx); // A, B, C, D...
|
|
return (
|
|
<button
|
|
key={idx}
|
|
onClick={() => selectAnswer(question.id, idx)}
|
|
className="glass-card"
|
|
style={{
|
|
padding: '1rem 1.25rem',
|
|
display: 'flex', alignItems: 'center', gap: '1rem',
|
|
cursor: 'pointer', border: 'none', textAlign: 'left',
|
|
outline: isSelected ? '2px solid var(--color-primary)' : 'none',
|
|
background: isSelected
|
|
? 'var(--bg-primary-alpha)'
|
|
: 'var(--color-surface-light)',
|
|
transition: 'all 0.2s ease',
|
|
borderRadius: 12,
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: '50%',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '0.875rem', fontWeight: 700, flexShrink: 0,
|
|
background: isSelected ? 'var(--color-primary)' : 'var(--glass-border)',
|
|
color: isSelected ? 'white' : 'var(--color-text-secondary)',
|
|
transition: 'all 0.2s ease',
|
|
}}>
|
|
{letter}
|
|
</div>
|
|
<span style={{
|
|
fontSize: '0.9rem', fontWeight: isSelected ? 600 : 400,
|
|
color: isSelected ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
|
}}>
|
|
{opt}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Footer */}
|
|
<div style={{
|
|
padding: '1rem 1.5rem',
|
|
background: 'var(--glass-bg)',
|
|
borderTop: '1px solid var(--glass-border)',
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
backdropFilter: 'blur(12px)',
|
|
}}>
|
|
<button
|
|
onClick={() => setCurrentQ(prev => Math.max(0, prev - 1))}
|
|
disabled={currentQ === 0}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '0.6rem 1.25rem', borderRadius: 10,
|
|
border: '1px solid var(--glass-border)',
|
|
background: 'var(--color-surface-light)',
|
|
color: currentQ === 0 ? 'var(--color-text-secondary)' : 'var(--color-text)',
|
|
cursor: currentQ === 0 ? 'not-allowed' : 'pointer',
|
|
fontWeight: 600, fontSize: '0.85rem',
|
|
opacity: currentQ === 0 ? 0.5 : 1,
|
|
}}
|
|
>
|
|
<ChevronLeft size={18} /> Anterior
|
|
</button>
|
|
|
|
{/* Question dots */}
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
{questions.map((q, i) => (
|
|
<button
|
|
key={q.id}
|
|
onClick={() => setCurrentQ(i)}
|
|
style={{
|
|
width: 28, height: 28, borderRadius: '50%',
|
|
border: 'none', cursor: 'pointer',
|
|
fontSize: '0.7rem', fontWeight: 700,
|
|
background: i === currentQ
|
|
? 'var(--color-primary)'
|
|
: answers[q.id] !== undefined
|
|
? 'var(--color-success)'
|
|
: 'var(--glass-border)',
|
|
color: (i === currentQ || answers[q.id] !== undefined) ? 'white' : 'var(--color-text-secondary)',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
{i + 1}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{currentQ < totalQ - 1 ? (
|
|
<button
|
|
onClick={() => setCurrentQ(prev => Math.min(totalQ - 1, prev + 1))}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '0.6rem 1.25rem', borderRadius: 10,
|
|
border: 'none',
|
|
background: 'var(--color-primary)',
|
|
color: 'white',
|
|
cursor: 'pointer',
|
|
fontWeight: 600, fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
Próxima <ChevronRight size={18} />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
if (answeredCount < totalQ) {
|
|
showAppConfirm(
|
|
`Você respondeu ${answeredCount} de ${totalQ} questões. Deseja finalizar mesmo assim?`,
|
|
() => handleSubmit()
|
|
);
|
|
} else {
|
|
handleSubmit();
|
|
}
|
|
}}
|
|
disabled={submitting}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '0.6rem 1.5rem', borderRadius: 10,
|
|
border: 'none',
|
|
background: 'var(--color-success)',
|
|
color: 'white',
|
|
cursor: submitting ? 'not-allowed' : 'pointer',
|
|
fontWeight: 700, fontSize: '0.85rem',
|
|
boxShadow: '0 4px 12px var(--bg-success-alpha)',
|
|
}}
|
|
>
|
|
<Send size={16} /> {submitting ? 'Enviando...' : 'Finalizar Prova'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{renderAppModal()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// RENDER: Result Screen
|
|
// ==========================================
|
|
if (view === 'result' && result) {
|
|
const isApproved = result.final_score >= 6;
|
|
const getMessage = () => {
|
|
if (result.percentage >= 90) return 'Excelente! Você arrasou! 🎉';
|
|
if (result.percentage >= 70) return 'Muito bem! Continue assim! 💪';
|
|
if (result.percentage >= 50) return 'Bom resultado. Pratique mais! 📚';
|
|
return 'Não desanime! Revise o conteúdo e tente novamente. 📖';
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, zIndex: 9999,
|
|
background: 'var(--color-surface)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
padding: '2rem',
|
|
}}>
|
|
<div className="glass-card animate-scale-in" style={{
|
|
maxWidth: 480, width: '100%', padding: '3rem 2rem', textAlign: 'center',
|
|
}}>
|
|
{/* Icon */}
|
|
<div style={{
|
|
width: 80, height: 80, borderRadius: '50%', margin: '0 auto 1.5rem',
|
|
background: isApproved ? 'var(--bg-success-alpha)' : 'var(--bg-danger-alpha)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
{isApproved
|
|
? <CheckCircle2 size={40} color="var(--color-success)" />
|
|
: <AlertTriangle size={40} color="var(--color-danger)" />
|
|
}
|
|
</div>
|
|
|
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 800, marginBottom: '0.5rem' }}>
|
|
Prova Finalizada!
|
|
</h2>
|
|
<p style={{ fontSize: '0.9rem', color: 'var(--color-text-secondary)', marginBottom: '2rem' }}>
|
|
{getMessage()}
|
|
</p>
|
|
|
|
{/* Score Display */}
|
|
<div style={{
|
|
display: 'flex', justifyContent: 'center', gap: '2rem',
|
|
marginBottom: '2rem', flexWrap: 'wrap',
|
|
}}>
|
|
<div>
|
|
<p style={{
|
|
fontSize: '3rem', fontWeight: 900, lineHeight: 1,
|
|
color: isApproved ? 'var(--color-success)' : 'var(--color-danger)',
|
|
}}>
|
|
{result.final_score.toFixed(1)}
|
|
</p>
|
|
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>
|
|
NOTA FINAL
|
|
</p>
|
|
</div>
|
|
<div style={{ width: 1, background: 'var(--glass-border)' }} />
|
|
<div>
|
|
<p style={{ fontSize: '3rem', fontWeight: 900, lineHeight: 1, color: 'var(--color-text)' }}>
|
|
{result.percentage.toFixed(0)}%
|
|
</p>
|
|
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>
|
|
APROVEITAMENTO
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div style={{
|
|
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem',
|
|
marginBottom: '2rem',
|
|
}}>
|
|
<div className="glass-card" style={{ padding: '1rem', textAlign: 'center' }}>
|
|
<p style={{ fontSize: '1.5rem', fontWeight: 700 }}>{result.total_questions}</p>
|
|
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>QUESTÕES</p>
|
|
</div>
|
|
<div className="glass-card" style={{ padding: '1rem', textAlign: 'center' }}>
|
|
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-success)' }}>{result.correct_count}</p>
|
|
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>ACERTOS</p>
|
|
</div>
|
|
<div className="glass-card" style={{ padding: '1rem', textAlign: 'center' }}>
|
|
<p style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--color-danger)' }}>{result.wrong_count}</p>
|
|
<p style={{ fontSize: '0.65rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>ERROS</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => { setView('listing'); setActiveExam(null); setResult(null); }}
|
|
style={{
|
|
width: '100%', padding: '0.875rem',
|
|
borderRadius: 12, border: 'none',
|
|
background: 'var(--color-primary)', color: 'white',
|
|
fontSize: '0.9rem', fontWeight: 700,
|
|
cursor: 'pointer', transition: 'all 0.2s',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
}}
|
|
>
|
|
<ArrowLeft size={18} /> Voltar às Atividades e Provas
|
|
</button>
|
|
</div>
|
|
{renderAppModal()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// RENDER: Listing (Default)
|
|
// ==========================================
|
|
const filteredExams = exams.filter(e => {
|
|
const isActivity = (e as any).evaluationType === 'activity';
|
|
return activeTab === 'activities' ? isActivity : !isActivity;
|
|
});
|
|
|
|
return (
|
|
<div className="page-container">
|
|
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
|
|
<h1 className="page-title">Atividades e Provas</h1>
|
|
<p className="page-subtitle">Provas e atividades disponíveis para você</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="animate-fade-in" style={{
|
|
display: 'flex', gap: '0.5rem', marginBottom: '2rem',
|
|
background: 'var(--color-surface-light)', padding: '0.5rem',
|
|
borderRadius: '12px', border: '1px solid var(--glass-border)',
|
|
width: 'fit-content'
|
|
}}>
|
|
<button
|
|
onClick={() => setActiveTab('exams')}
|
|
style={{
|
|
padding: '0.5rem 1.5rem',
|
|
borderRadius: '8px',
|
|
border: 'none',
|
|
fontSize: '0.85rem',
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
background: activeTab === 'exams' ? 'var(--color-primary)' : 'transparent',
|
|
color: activeTab === 'exams' ? 'white' : 'var(--color-text-secondary)',
|
|
boxShadow: activeTab === 'exams' ? '0 2px 8px var(--bg-primary-alpha)' : 'none'
|
|
}}
|
|
>
|
|
Provas ({exams.filter(e => (e as any).evaluationType !== 'activity').length})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('activities')}
|
|
style={{
|
|
padding: '0.5rem 1.5rem',
|
|
borderRadius: '8px',
|
|
border: 'none',
|
|
fontSize: '0.85rem',
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
background: activeTab === 'activities' ? 'var(--color-info, #0369a1)' : 'transparent',
|
|
color: activeTab === 'activities' ? 'white' : 'var(--color-text-secondary)',
|
|
boxShadow: activeTab === 'activities' ? '0 2px 8px rgba(3, 105, 161, 0.2)' : 'none'
|
|
}}
|
|
>
|
|
Atividades ({exams.filter(e => (e as any).evaluationType === 'activity').length})
|
|
</button>
|
|
</div>
|
|
|
|
{filteredExams.length === 0 ? (
|
|
<div className="glass-card animate-fade-in" style={{
|
|
padding: '4rem 2rem', textAlign: 'center',
|
|
color: 'var(--color-text-secondary)',
|
|
}}>
|
|
<ClipboardList size={56} style={{ opacity: 0.2, marginBottom: '1rem' }} />
|
|
<p style={{ fontSize: '1rem', fontWeight: 600 }}>
|
|
Nenhuma {activeTab === 'activities' ? 'atividade' : 'prova'} disponível no momento.
|
|
</p>
|
|
<p style={{ fontSize: '0.8rem', marginTop: 4 }}>
|
|
As {activeTab === 'activities' ? 'atividades' : 'provas'} aparecerão aqui quando forem publicadas pelo professor.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
gap: '1rem',
|
|
}} className="animate-fade-in stagger-children">
|
|
{filteredExams.map(exam => {
|
|
const sub = getSubmission(exam.id);
|
|
const isDone = !!sub;
|
|
|
|
return (
|
|
<div key={exam.id} className="glass-card" style={{
|
|
padding: '1.5rem',
|
|
borderTop: isDone ? '3px solid var(--color-success)' : '3px solid var(--color-primary)',
|
|
position: 'relative', overflow: 'hidden',
|
|
}}>
|
|
{isDone && (
|
|
<div style={{
|
|
position: 'absolute', top: 12, right: 12,
|
|
background: 'var(--bg-success-alpha)', color: 'var(--color-success)',
|
|
padding: '4px 10px', borderRadius: 20,
|
|
fontSize: '0.65rem', fontWeight: 700,
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
}}>
|
|
<CheckCircle2 size={12} /> REALIZADA
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
|
<span style={{
|
|
padding: '2px 8px',
|
|
borderRadius: '6px',
|
|
fontSize: '0.65rem',
|
|
fontWeight: 800,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
backgroundColor: (exam as any).evaluationType === 'activity' ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha, #ede9fe)',
|
|
color: (exam as any).evaluationType === 'activity' ? 'var(--color-info, #0369a1)' : 'var(--color-primary, #6d28d9)',
|
|
border: `1px solid ${(exam as any).evaluationType === 'activity' ? '#bae6fd' : '#ddd6fe'}`
|
|
}}>
|
|
{(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
|
|
</span>
|
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--color-text-secondary)' }}>
|
|
Vale: {(exam as any).maxScore ?? 10} pts
|
|
</span>
|
|
</div>
|
|
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: 4, paddingRight: isDone ? 90 : 0 }}>
|
|
{exam.title}
|
|
</h3>
|
|
{(exam as any).description && (
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--color-text-secondary)', lineHeight: 1.4 }}>
|
|
{(exam as any).description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{
|
|
display: 'flex', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap',
|
|
}}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
fontSize: '0.75rem', color: 'var(--color-text-secondary)',
|
|
}}>
|
|
<Clock size={14} /> {exam.durationMinutes} minutos
|
|
</div>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
fontSize: '0.75rem', color: 'var(--color-text-secondary)',
|
|
}}>
|
|
<ClipboardList size={14} /> {exam.questions.length} questões
|
|
</div>
|
|
</div>
|
|
|
|
{isDone ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
<div style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '0.75rem 1rem', borderRadius: 10,
|
|
background: 'var(--bg-success-alpha)',
|
|
}}>
|
|
<div>
|
|
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>SUA NOTA</p>
|
|
<p style={{ fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-success)' }}>
|
|
{sub!.final_score.toFixed(1)}
|
|
</p>
|
|
</div>
|
|
<div style={{ textAlign: 'right' }}>
|
|
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>ACERTOS</p>
|
|
<p style={{ fontSize: '1rem', fontWeight: 700 }}>
|
|
{sub!.correct_count}/{sub!.total_questions}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
|
|
{(exam as any).allowRetake ? (
|
|
<button
|
|
onClick={() => {
|
|
showAppConfirm('Deseja realmente refazer? Sua nota anterior será substituída.', () => startExam(exam));
|
|
}}
|
|
style={{
|
|
flex: 1, padding: '0.65rem',
|
|
borderRadius: 10, border: '1px solid var(--color-primary-alpha)',
|
|
background: 'white', color: 'var(--color-primary)',
|
|
fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
}}
|
|
>
|
|
<RefreshCw size={14} /> Refazer
|
|
</button>
|
|
) : (
|
|
<div style={{
|
|
flex: 1, padding: '0.65rem', borderRadius: 10,
|
|
background: 'var(--color-surface-light)', color: 'var(--color-text-secondary)',
|
|
fontSize: '0.7rem', fontWeight: 600, textAlign: 'center',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
opacity: 0.6
|
|
}}>
|
|
<XCircle size={14} /> Bloqueado
|
|
</div>
|
|
)}
|
|
|
|
<div style={{
|
|
padding: '0.65rem', borderRadius: 10,
|
|
background: (exam as any).allowRetake ? 'var(--bg-success-alpha)' : 'var(--bg-danger-alpha)',
|
|
color: (exam as any).allowRetake ? 'var(--color-success)' : 'var(--color-danger)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
|
}} title={(exam as any).allowRetake ? 'Retentativa liberada' : 'Retentativa bloqueada'}>
|
|
{(exam as any).allowRetake ? <Unlock size={16} /> : <Lock size={16} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => startExam(exam)}
|
|
style={{
|
|
width: '100%', padding: '0.75rem',
|
|
borderRadius: 10, border: 'none',
|
|
background: 'var(--gradient-primary)', color: 'white',
|
|
fontSize: '0.875rem', fontWeight: 700,
|
|
cursor: 'pointer', transition: 'all 0.2s',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
boxShadow: '0 4px 15px var(--bg-primary-alpha)',
|
|
}}
|
|
>
|
|
<Award size={18} /> {(exam as any).evaluationType === 'activity' ? 'Iniciar Atividade' : 'Iniciar Prova'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{renderAppModal()}
|
|
</div>
|
|
);
|
|
}
|