@hed-hog/lms 0.0.268 → 0.0.270
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hedhog/data/menu.yaml +8 -1
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1387 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +4 -4
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +1237 -0
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +2642 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +825 -727
- package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +976 -0
- package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +931 -0
- package/hedhog/frontend/app/exams/page.tsx.ejs +9 -7
- package/hedhog/frontend/app/training/page.tsx.ejs +3 -3
- package/hedhog/frontend/messages/en.json +703 -14
- package/hedhog/frontend/messages/pt.json +863 -174
- package/hedhog/query/triggers.sql +0 -0
- package/hedhog/table/certificate.yaml +89 -0
- package/hedhog/table/certificate_template.yaml +24 -0
- package/hedhog/table/course.yaml +67 -1
- package/hedhog/table/course_category.yaml +22 -0
- package/hedhog/table/course_class_attendance.yaml +34 -0
- package/hedhog/table/course_class_group.yaml +58 -0
- package/hedhog/table/course_class_session.yaml +38 -0
- package/hedhog/table/course_class_session_instructor.yaml +27 -0
- package/hedhog/table/course_enrollment.yaml +45 -0
- package/hedhog/table/course_image.yaml +33 -0
- package/hedhog/table/course_lesson.yaml +35 -0
- package/hedhog/table/course_lesson_file.yaml +23 -0
- package/hedhog/table/course_lesson_instructor.yaml +27 -0
- package/hedhog/table/course_lesson_progress.yaml +40 -0
- package/hedhog/table/course_lesson_question.yaml +24 -0
- package/hedhog/table/course_module.yaml +25 -0
- package/hedhog/table/course_prerequisite.yaml +48 -0
- package/hedhog/table/evaluation_rating.yaml +30 -0
- package/hedhog/table/evaluation_topic.yaml +68 -0
- package/hedhog/table/exam.yaml +91 -0
- package/hedhog/table/exam_answer.yaml +40 -0
- package/hedhog/table/exam_attempt.yaml +51 -0
- package/hedhog/table/exam_image.yaml +33 -0
- package/hedhog/table/exam_option.yaml +25 -0
- package/hedhog/table/exam_question.yaml +24 -0
- package/hedhog/table/image_type.yaml +28 -0
- package/hedhog/table/instructor.yaml +23 -0
- package/hedhog/table/learning_path.yaml +49 -0
- package/hedhog/table/learning_path_enrollment.yaml +33 -0
- package/hedhog/table/learning_path_image.yaml +33 -0
- package/hedhog/table/learning_path_step.yaml +43 -0
- package/hedhog/table/question.yaml +15 -0
- package/package.json +9 -6
- package/src/index.ts +1 -1
- package/src/lms.module.ts +15 -15
- package/hedhog/table/classes.yaml +0 -3
- package/hedhog/table/exams.yaml +0 -3
- package/hedhog/table/reports.yaml +0 -3
- package/hedhog/table/training.yaml +0 -3
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Page, PageHeader } from '@/components/entity-list';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from '@/components/ui/dialog';
|
|
15
|
+
import { Progress } from '@/components/ui/progress';
|
|
16
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
17
|
+
import {
|
|
18
|
+
AlertTriangle,
|
|
19
|
+
BookmarkCheck,
|
|
20
|
+
CheckCircle2,
|
|
21
|
+
ChevronLeft,
|
|
22
|
+
ChevronRight,
|
|
23
|
+
CircleDot,
|
|
24
|
+
Clock,
|
|
25
|
+
Flag,
|
|
26
|
+
GraduationCap,
|
|
27
|
+
Save,
|
|
28
|
+
Send,
|
|
29
|
+
X,
|
|
30
|
+
} from 'lucide-react';
|
|
31
|
+
import { useTranslations } from 'next-intl';
|
|
32
|
+
import Link from 'next/link';
|
|
33
|
+
import { useEffect, useRef, useState } from 'react';
|
|
34
|
+
import { toast } from 'sonner';
|
|
35
|
+
|
|
36
|
+
interface Alternativa {
|
|
37
|
+
id: string;
|
|
38
|
+
texto: string;
|
|
39
|
+
correta: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Questao {
|
|
43
|
+
id: string;
|
|
44
|
+
enunciado: string;
|
|
45
|
+
pontuacao: number;
|
|
46
|
+
alternativas: Alternativa[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const examQuestions: Questao[] = [
|
|
50
|
+
{
|
|
51
|
+
id: 'q1',
|
|
52
|
+
enunciado:
|
|
53
|
+
'Qual hook do React e utilizado para gerenciar estado local em componentes funcionais?',
|
|
54
|
+
pontuacao: 2,
|
|
55
|
+
alternativas: [
|
|
56
|
+
{ id: 'a1', texto: 'useEffect', correta: false },
|
|
57
|
+
{ id: 'a2', texto: 'useState', correta: true },
|
|
58
|
+
{ id: 'a3', texto: 'useContext', correta: false },
|
|
59
|
+
{ id: 'a4', texto: 'useReducer', correta: false },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'q2',
|
|
64
|
+
enunciado: 'O que o Virtual DOM faz no React?',
|
|
65
|
+
pontuacao: 2,
|
|
66
|
+
alternativas: [
|
|
67
|
+
{ id: 'a5', texto: 'Substitui o DOM real completamente', correta: false },
|
|
68
|
+
{
|
|
69
|
+
id: 'a6',
|
|
70
|
+
texto:
|
|
71
|
+
'Cria uma copia do DOM para comparar e atualizar apenas o necessario',
|
|
72
|
+
correta: true,
|
|
73
|
+
},
|
|
74
|
+
{ id: 'a7', texto: 'Elimina a necessidade de HTML', correta: false },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'q3',
|
|
79
|
+
enunciado: 'Qual e a principal diferenca entre props e state no React?',
|
|
80
|
+
pontuacao: 3,
|
|
81
|
+
alternativas: [
|
|
82
|
+
{
|
|
83
|
+
id: 'a8',
|
|
84
|
+
texto: 'Props sao mutaveis, state sao imutaveis',
|
|
85
|
+
correta: false,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'a9',
|
|
89
|
+
texto:
|
|
90
|
+
'Props sao passados pelo componente pai, state e gerenciado internamente',
|
|
91
|
+
correta: true,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'a10',
|
|
95
|
+
texto: 'Nao ha diferenca pratica entre eles',
|
|
96
|
+
correta: false,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'a11',
|
|
100
|
+
texto: 'State so existe em class components',
|
|
101
|
+
correta: false,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'a12',
|
|
105
|
+
texto: 'Props nao podem receber funcoes como valor',
|
|
106
|
+
correta: false,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'q4',
|
|
112
|
+
enunciado:
|
|
113
|
+
'Qual metodo do ciclo de vida em class components e equivalente ao useEffect com array de dependencias vazio?',
|
|
114
|
+
pontuacao: 2,
|
|
115
|
+
alternativas: [
|
|
116
|
+
{ id: 'a13', texto: 'componentDidMount', correta: true },
|
|
117
|
+
{ id: 'a14', texto: 'componentWillUpdate', correta: false },
|
|
118
|
+
{ id: 'a15', texto: 'componentDidUpdate', correta: false },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'q5',
|
|
123
|
+
enunciado: 'Para que serve o React.memo?',
|
|
124
|
+
pontuacao: 1,
|
|
125
|
+
alternativas: [
|
|
126
|
+
{
|
|
127
|
+
id: 'a16',
|
|
128
|
+
texto: 'Para memorizar valores computados em funcoes pesadas',
|
|
129
|
+
correta: false,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'a17',
|
|
133
|
+
texto: 'Para evitar re-renderizacoes desnecessarias de componentes',
|
|
134
|
+
correta: true,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'q6',
|
|
140
|
+
enunciado: 'Qual das opcoes abaixo NAO e uma regra dos Hooks no React?',
|
|
141
|
+
pontuacao: 2,
|
|
142
|
+
alternativas: [
|
|
143
|
+
{
|
|
144
|
+
id: 'a18',
|
|
145
|
+
texto: 'Hooks devem ser chamados no nivel superior do componente',
|
|
146
|
+
correta: false,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'a19',
|
|
150
|
+
texto: 'Hooks podem ser chamados dentro de condicionais',
|
|
151
|
+
correta: true,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'a20',
|
|
155
|
+
texto:
|
|
156
|
+
'Hooks so devem ser chamados em componentes funcionais ou custom hooks',
|
|
157
|
+
correta: false,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'a21',
|
|
161
|
+
texto: 'Hooks devem ser chamados na mesma ordem a cada render',
|
|
162
|
+
correta: false,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'q7',
|
|
168
|
+
enunciado: 'O que o useCallback faz?',
|
|
169
|
+
pontuacao: 2,
|
|
170
|
+
alternativas: [
|
|
171
|
+
{
|
|
172
|
+
id: 'a22',
|
|
173
|
+
texto: 'Memoriza o resultado de uma funcao',
|
|
174
|
+
correta: false,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'a23',
|
|
178
|
+
texto: 'Memoriza a referencia de uma funcao entre re-renders',
|
|
179
|
+
correta: true,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'a24',
|
|
183
|
+
texto: 'Chama uma funcao apos a renderizacao',
|
|
184
|
+
correta: false,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'q8',
|
|
190
|
+
enunciado: 'Em qual situacao voce usaria useReducer ao inves de useState?',
|
|
191
|
+
pontuacao: 3,
|
|
192
|
+
alternativas: [
|
|
193
|
+
{
|
|
194
|
+
id: 'a25',
|
|
195
|
+
texto: 'Quando o estado e um simples booleano',
|
|
196
|
+
correta: false,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 'a26',
|
|
200
|
+
texto:
|
|
201
|
+
'Quando a logica de atualizacao do estado e complexa com multiplas sub-valores',
|
|
202
|
+
correta: true,
|
|
203
|
+
},
|
|
204
|
+
{ id: 'a27', texto: 'Quando nao precisa de re-renders', correta: false },
|
|
205
|
+
{
|
|
206
|
+
id: 'a28',
|
|
207
|
+
texto: 'Quando o componente nao tem filhos',
|
|
208
|
+
correta: false,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const EXAM_TITLE = 'Prova Final - React Avancado';
|
|
215
|
+
const EXAM_TIME_MINUTES = 120;
|
|
216
|
+
|
|
217
|
+
export default function TentativaPage() {
|
|
218
|
+
const t = useTranslations('lms.AttemptPage');
|
|
219
|
+
const [started, setStarted] = useState(false);
|
|
220
|
+
const [finished, setFinished] = useState(false);
|
|
221
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
222
|
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
223
|
+
const [flagged, setFlagged] = useState<Set<string>>(new Set());
|
|
224
|
+
const [timeLeft, setTimeLeft] = useState(EXAM_TIME_MINUTES * 60);
|
|
225
|
+
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
|
|
226
|
+
const [timeUpDialogOpen, setTimeUpDialogOpen] = useState(false);
|
|
227
|
+
const [showNav, setShowNav] = useState(false);
|
|
228
|
+
const [score, setScore] = useState<{
|
|
229
|
+
total: number;
|
|
230
|
+
max: number;
|
|
231
|
+
percent: number;
|
|
232
|
+
} | null>(null);
|
|
233
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
234
|
+
|
|
235
|
+
// Timer
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (started && !finished) {
|
|
238
|
+
timerRef.current = setInterval(() => {
|
|
239
|
+
setTimeLeft((prev) => {
|
|
240
|
+
if (prev <= 1) {
|
|
241
|
+
clearInterval(timerRef.current!);
|
|
242
|
+
setTimeUpDialogOpen(true);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
return prev - 1;
|
|
246
|
+
});
|
|
247
|
+
}, 1000);
|
|
248
|
+
return () => {
|
|
249
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}, [started, finished]);
|
|
253
|
+
|
|
254
|
+
// Auto-save
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (started && !finished && Object.keys(answers).length > 0) {
|
|
257
|
+
const timeout = setTimeout(() => {
|
|
258
|
+
// simulate auto-save
|
|
259
|
+
}, 2000);
|
|
260
|
+
return () => clearTimeout(timeout);
|
|
261
|
+
}
|
|
262
|
+
}, [answers, started, finished]);
|
|
263
|
+
|
|
264
|
+
const currentQuestion = examQuestions[currentIndex];
|
|
265
|
+
const answeredCount = Object.keys(answers).length;
|
|
266
|
+
const progressPercent = (answeredCount / examQuestions.length) * 100;
|
|
267
|
+
|
|
268
|
+
function formatTime(seconds: number) {
|
|
269
|
+
const h = Math.floor(seconds / 3600);
|
|
270
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
271
|
+
const s = seconds % 60;
|
|
272
|
+
if (h > 0)
|
|
273
|
+
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
274
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function selectAnswer(questionId: string, alternativaId: string) {
|
|
278
|
+
setAnswers((prev) => ({ ...prev, [questionId]: alternativaId }));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function toggleFlag(questionId: string) {
|
|
282
|
+
setFlagged((prev) => {
|
|
283
|
+
const next = new Set(prev);
|
|
284
|
+
if (next.has(questionId)) next.delete(questionId);
|
|
285
|
+
else next.add(questionId);
|
|
286
|
+
return next;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function goToQuestion(index: number) {
|
|
291
|
+
setCurrentIndex(index);
|
|
292
|
+
setShowNav(false);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function handleSaveProgress() {
|
|
296
|
+
toast.success(t('examInProgress.progressSaved'));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function calculateScore() {
|
|
300
|
+
let total = 0;
|
|
301
|
+
let max = 0;
|
|
302
|
+
examQuestions.forEach((q) => {
|
|
303
|
+
max += q.pontuacao;
|
|
304
|
+
const selectedAltId = answers[q.id];
|
|
305
|
+
if (selectedAltId) {
|
|
306
|
+
const alt = q.alternativas.find((a) => a.id === selectedAltId);
|
|
307
|
+
if (alt?.correta) total += q.pontuacao;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return { total, max, percent: Math.round((total / max) * 100) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handleSubmit() {
|
|
314
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
315
|
+
const result = calculateScore();
|
|
316
|
+
setScore(result);
|
|
317
|
+
setFinished(true);
|
|
318
|
+
setSubmitDialogOpen(false);
|
|
319
|
+
setTimeUpDialogOpen(false);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function handleForceSubmit() {
|
|
323
|
+
handleSubmit();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Start screen
|
|
327
|
+
if (!started) {
|
|
328
|
+
return (
|
|
329
|
+
<Page>
|
|
330
|
+
<PageHeader
|
|
331
|
+
breadcrumbs={[
|
|
332
|
+
{
|
|
333
|
+
label: t('breadcrumbs.home'),
|
|
334
|
+
href: '/',
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
label: t('breadcrumbs.exams'),
|
|
338
|
+
href: '/lms/exams',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
label: t('breadcrumbs.attemptExam'),
|
|
342
|
+
},
|
|
343
|
+
]}
|
|
344
|
+
/>
|
|
345
|
+
<div className="flex justify-center">
|
|
346
|
+
<motion.div
|
|
347
|
+
initial={{ opacity: 0, y: 20 }}
|
|
348
|
+
animate={{ opacity: 1, y: 0 }}
|
|
349
|
+
transition={{ duration: 0.5 }}
|
|
350
|
+
>
|
|
351
|
+
<Card className="w-full max-w-lg">
|
|
352
|
+
<CardHeader className="text-center">
|
|
353
|
+
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-2xl bg-orange-500">
|
|
354
|
+
<GraduationCap className="size-8 text-background" />
|
|
355
|
+
</div>
|
|
356
|
+
<CardTitle className="text-2xl">{EXAM_TITLE}</CardTitle>
|
|
357
|
+
</CardHeader>
|
|
358
|
+
<CardContent className="flex flex-col gap-4">
|
|
359
|
+
<div className="grid grid-cols-2 gap-3">
|
|
360
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
361
|
+
<p className="text-2xl font-bold">{examQuestions.length}</p>
|
|
362
|
+
<p className="text-xs text-muted-foreground">
|
|
363
|
+
{t('startScreen.questions')}
|
|
364
|
+
</p>
|
|
365
|
+
</div>
|
|
366
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
367
|
+
<p className="text-2xl font-bold">{EXAM_TIME_MINUTES}</p>
|
|
368
|
+
<p className="text-xs text-muted-foreground">
|
|
369
|
+
{t('startScreen.minutes')}
|
|
370
|
+
</p>
|
|
371
|
+
</div>
|
|
372
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
373
|
+
<p className="text-2xl font-bold">
|
|
374
|
+
{examQuestions.reduce((a, q) => a + q.pontuacao, 0)}
|
|
375
|
+
</p>
|
|
376
|
+
<p className="text-xs text-muted-foreground">
|
|
377
|
+
{t('startScreen.points')}
|
|
378
|
+
</p>
|
|
379
|
+
</div>
|
|
380
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
381
|
+
<p className="text-2xl font-bold">7.0</p>
|
|
382
|
+
<p className="text-xs text-muted-foreground">
|
|
383
|
+
{t('startScreen.minGrade')}
|
|
384
|
+
</p>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
<div className="rounded-lg border p-4">
|
|
389
|
+
<h3 className="mb-2 text-sm font-medium">
|
|
390
|
+
{t('startScreen.instructions')}
|
|
391
|
+
</h3>
|
|
392
|
+
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
|
|
393
|
+
<li>- {t('startScreen.instructionsList.item1')}</li>
|
|
394
|
+
<li>- {t('startScreen.instructionsList.item2')}</li>
|
|
395
|
+
<li>- {t('startScreen.instructionsList.item3')}</li>
|
|
396
|
+
<li>- {t('startScreen.instructionsList.item4')}</li>
|
|
397
|
+
<li>- {t('startScreen.instructionsList.item5')}</li>
|
|
398
|
+
</ul>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
<div className="flex gap-3">
|
|
402
|
+
<Button
|
|
403
|
+
className="flex-1 gap-2"
|
|
404
|
+
onClick={() => setStarted(true)}
|
|
405
|
+
>
|
|
406
|
+
{t('startScreen.startButton')}
|
|
407
|
+
</Button>
|
|
408
|
+
</div>
|
|
409
|
+
</CardContent>
|
|
410
|
+
</Card>
|
|
411
|
+
</motion.div>
|
|
412
|
+
</div>
|
|
413
|
+
</Page>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Results screen
|
|
418
|
+
if (finished && score) {
|
|
419
|
+
const passed = score.percent >= 70;
|
|
420
|
+
return (
|
|
421
|
+
<Page>
|
|
422
|
+
<PageHeader
|
|
423
|
+
breadcrumbs={[
|
|
424
|
+
{
|
|
425
|
+
label: t('breadcrumbs.home'),
|
|
426
|
+
href: '/',
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
label: t('breadcrumbs.exams'),
|
|
430
|
+
href: '/lms/exams',
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
label: t('breadcrumbs.examResult'),
|
|
434
|
+
},
|
|
435
|
+
]}
|
|
436
|
+
/>
|
|
437
|
+
|
|
438
|
+
<div className="flex justify-center">
|
|
439
|
+
<motion.div
|
|
440
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
441
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
442
|
+
transition={{ duration: 0.5 }}
|
|
443
|
+
>
|
|
444
|
+
<Card className="w-full max-w-lg">
|
|
445
|
+
<CardHeader className="text-center">
|
|
446
|
+
<motion.div
|
|
447
|
+
initial={{ scale: 0 }}
|
|
448
|
+
animate={{ scale: 1 }}
|
|
449
|
+
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
|
|
450
|
+
className={`mx-auto mb-4 flex size-20 items-center justify-center rounded-full ${passed ? 'bg-primary' : 'bg-muted'}`}
|
|
451
|
+
>
|
|
452
|
+
{passed ? (
|
|
453
|
+
<CheckCircle2 className="size-10 text-primary-foreground" />
|
|
454
|
+
) : (
|
|
455
|
+
<X className="size-10 text-muted-foreground" />
|
|
456
|
+
)}
|
|
457
|
+
</motion.div>
|
|
458
|
+
<CardTitle className="text-2xl">
|
|
459
|
+
{passed ? t('resultScreen.passed') : t('resultScreen.failed')}
|
|
460
|
+
</CardTitle>
|
|
461
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
462
|
+
{EXAM_TITLE}
|
|
463
|
+
</p>
|
|
464
|
+
</CardHeader>
|
|
465
|
+
<CardContent className="flex flex-col gap-4">
|
|
466
|
+
<div className="text-center">
|
|
467
|
+
<motion.p
|
|
468
|
+
initial={{ opacity: 0 }}
|
|
469
|
+
animate={{ opacity: 1 }}
|
|
470
|
+
transition={{ delay: 0.4 }}
|
|
471
|
+
className="text-5xl font-bold"
|
|
472
|
+
>
|
|
473
|
+
{score.percent}%
|
|
474
|
+
</motion.p>
|
|
475
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
476
|
+
{t('resultScreen.scoreText', {
|
|
477
|
+
total: score.total,
|
|
478
|
+
max: score.max,
|
|
479
|
+
})}
|
|
480
|
+
</p>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<div className="grid grid-cols-3 gap-3">
|
|
484
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
485
|
+
<p className="text-lg font-bold">{answeredCount}</p>
|
|
486
|
+
<p className="text-xs text-muted-foreground">
|
|
487
|
+
{t('resultScreen.answered')}
|
|
488
|
+
</p>
|
|
489
|
+
</div>
|
|
490
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
491
|
+
<p className="text-lg font-bold">
|
|
492
|
+
{examQuestions.length - answeredCount}
|
|
493
|
+
</p>
|
|
494
|
+
<p className="text-xs text-muted-foreground">
|
|
495
|
+
{t('resultScreen.blank')}
|
|
496
|
+
</p>
|
|
497
|
+
</div>
|
|
498
|
+
<div className="rounded-lg bg-muted p-3 text-center">
|
|
499
|
+
<p className="text-lg font-bold">
|
|
500
|
+
{formatTime(EXAM_TIME_MINUTES * 60 - timeLeft)}
|
|
501
|
+
</p>
|
|
502
|
+
<p className="text-xs text-muted-foreground">
|
|
503
|
+
{t('resultScreen.timeUsed')}
|
|
504
|
+
</p>
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
|
|
508
|
+
{/* Review answers */}
|
|
509
|
+
<div className="h-auto rounded-lg border p-3">
|
|
510
|
+
<p className="mb-2 text-sm font-medium">
|
|
511
|
+
{t('resultScreen.answersSummary')}
|
|
512
|
+
</p>
|
|
513
|
+
<div className="flex flex-col gap-2">
|
|
514
|
+
{examQuestions.map((q, i) => {
|
|
515
|
+
const selectedAltId = answers[q.id];
|
|
516
|
+
const selectedAlt = q.alternativas.find(
|
|
517
|
+
(a) => a.id === selectedAltId
|
|
518
|
+
);
|
|
519
|
+
const isCorrect = selectedAlt?.correta;
|
|
520
|
+
return (
|
|
521
|
+
<div
|
|
522
|
+
key={q.id}
|
|
523
|
+
className="flex items-center gap-2 text-sm"
|
|
524
|
+
>
|
|
525
|
+
<span
|
|
526
|
+
className={`flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
|
527
|
+
!selectedAltId
|
|
528
|
+
? 'bg-muted text-muted-foreground'
|
|
529
|
+
: isCorrect
|
|
530
|
+
? 'bg-primary text-primary-foreground'
|
|
531
|
+
: 'bg-destructive/10 text-destructive'
|
|
532
|
+
}`}
|
|
533
|
+
>
|
|
534
|
+
{i + 1}
|
|
535
|
+
</span>
|
|
536
|
+
<span className="flex-1 truncate text-muted-foreground">
|
|
537
|
+
{q.enunciado.substring(0, 50)}...
|
|
538
|
+
</span>
|
|
539
|
+
<span className="shrink-0 text-xs font-medium">
|
|
540
|
+
{!selectedAltId
|
|
541
|
+
? t('resultScreen.noAnswer')
|
|
542
|
+
: isCorrect
|
|
543
|
+
? `+${q.pontuacao}`
|
|
544
|
+
: '0'}
|
|
545
|
+
</span>
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
})}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<div className="flex gap-3">
|
|
553
|
+
<Button variant="outline" className="flex-1" asChild>
|
|
554
|
+
<Link href="/lms/exams">
|
|
555
|
+
{t('resultScreen.backButton')}
|
|
556
|
+
</Link>
|
|
557
|
+
</Button>
|
|
558
|
+
</div>
|
|
559
|
+
</CardContent>
|
|
560
|
+
</Card>
|
|
561
|
+
</motion.div>
|
|
562
|
+
</div>
|
|
563
|
+
</Page>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Exam in progress
|
|
568
|
+
return (
|
|
569
|
+
<Page>
|
|
570
|
+
<PageHeader
|
|
571
|
+
breadcrumbs={[
|
|
572
|
+
{
|
|
573
|
+
label: t('breadcrumbs.home'),
|
|
574
|
+
href: '/',
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
label: t('breadcrumbs.exams'),
|
|
578
|
+
href: '/lms/exams',
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
label: t('breadcrumbs.attemptExam'),
|
|
582
|
+
},
|
|
583
|
+
]}
|
|
584
|
+
actions={
|
|
585
|
+
<div className="flex items-center gap-3">
|
|
586
|
+
{/* Timer */}
|
|
587
|
+
<div
|
|
588
|
+
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-mono font-bold ${
|
|
589
|
+
timeLeft < 300
|
|
590
|
+
? 'bg-destructive/10 text-destructive'
|
|
591
|
+
: timeLeft < 600
|
|
592
|
+
? 'bg-primary/10 text-primary'
|
|
593
|
+
: 'bg-primary/10 text-primary'
|
|
594
|
+
}`}
|
|
595
|
+
>
|
|
596
|
+
<Clock className="size-4" />
|
|
597
|
+
{formatTime(timeLeft)}
|
|
598
|
+
</div>
|
|
599
|
+
|
|
600
|
+
{/* Save */}
|
|
601
|
+
<Button
|
|
602
|
+
variant="outline"
|
|
603
|
+
className="hidden gap-1.5 sm:flex"
|
|
604
|
+
onClick={handleSaveProgress}
|
|
605
|
+
>
|
|
606
|
+
<Save className="size-4" /> {t('examInProgress.saveButton')}
|
|
607
|
+
</Button>
|
|
608
|
+
|
|
609
|
+
{/* Submit */}
|
|
610
|
+
<Button
|
|
611
|
+
className="gap-1.5"
|
|
612
|
+
onClick={() => setSubmitDialogOpen(true)}
|
|
613
|
+
>
|
|
614
|
+
<Send className="size-4" />
|
|
615
|
+
<span className="hidden sm:inline">
|
|
616
|
+
{t('examInProgress.finishButton')}
|
|
617
|
+
</span>
|
|
618
|
+
</Button>
|
|
619
|
+
</div>
|
|
620
|
+
}
|
|
621
|
+
/>
|
|
622
|
+
|
|
623
|
+
{/* Progress bar */}
|
|
624
|
+
<div className="px-4 pb-2">
|
|
625
|
+
<Progress value={progressPercent} className="h-1.5" />
|
|
626
|
+
</div>
|
|
627
|
+
|
|
628
|
+
{/* Top bar */}
|
|
629
|
+
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur">
|
|
630
|
+
<div className="flex h-14 items-center justify-between px-4">
|
|
631
|
+
<div className="flex items-center gap-3">
|
|
632
|
+
<div>
|
|
633
|
+
<p className="text-sm font-medium leading-none">{EXAM_TITLE}</p>
|
|
634
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
635
|
+
{t('examInProgress.question')} {currentIndex + 1}{' '}
|
|
636
|
+
{t('examInProgress.of')} {examQuestions.length}
|
|
637
|
+
</p>
|
|
638
|
+
</div>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
</header>
|
|
642
|
+
|
|
643
|
+
<div className="flex gap-6">
|
|
644
|
+
{/* Question area */}
|
|
645
|
+
<div className="flex-1">
|
|
646
|
+
<AnimatePresence mode="wait">
|
|
647
|
+
<motion.div
|
|
648
|
+
key={currentQuestion.id}
|
|
649
|
+
initial={{ opacity: 0, x: 20 }}
|
|
650
|
+
animate={{ opacity: 1, x: 0 }}
|
|
651
|
+
exit={{ opacity: 0, x: -20 }}
|
|
652
|
+
transition={{ duration: 0.25 }}
|
|
653
|
+
>
|
|
654
|
+
<Card>
|
|
655
|
+
<CardContent className="p-6">
|
|
656
|
+
{/* Question header */}
|
|
657
|
+
<div className="mb-6 flex items-start justify-between gap-4">
|
|
658
|
+
<div className="flex items-start gap-3">
|
|
659
|
+
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-base font-bold text-primary-foreground">
|
|
660
|
+
{currentIndex + 1}
|
|
661
|
+
</div>
|
|
662
|
+
<div>
|
|
663
|
+
<p className="text-base font-medium leading-relaxed">
|
|
664
|
+
{currentQuestion.enunciado}
|
|
665
|
+
</p>
|
|
666
|
+
<div className="mt-2 flex items-center gap-2">
|
|
667
|
+
<Badge variant="outline" className="text-xs">
|
|
668
|
+
{currentQuestion.pontuacao} pt
|
|
669
|
+
{currentQuestion.pontuacao !== 1 ? 's' : ''}
|
|
670
|
+
</Badge>
|
|
671
|
+
{flagged.has(currentQuestion.id) && (
|
|
672
|
+
<Badge variant="secondary" className="text-xs">
|
|
673
|
+
{t('examInProgress.marked')}
|
|
674
|
+
</Badge>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
<Button
|
|
680
|
+
variant="ghost"
|
|
681
|
+
size="icon"
|
|
682
|
+
className={`shrink-0 ${flagged.has(currentQuestion.id) ? 'text-primary' : 'text-muted-foreground'}`}
|
|
683
|
+
onClick={() => toggleFlag(currentQuestion.id)}
|
|
684
|
+
aria-label={t('examInProgress.markQuestion')}
|
|
685
|
+
>
|
|
686
|
+
<Flag
|
|
687
|
+
className={`size-5 ${flagged.has(currentQuestion.id) ? 'fill-current' : ''}`}
|
|
688
|
+
/>
|
|
689
|
+
</Button>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
{/* Alternatives */}
|
|
693
|
+
<div className="flex flex-col gap-3">
|
|
694
|
+
{currentQuestion.alternativas.map((alt, i) => {
|
|
695
|
+
const isSelected = answers[currentQuestion.id] === alt.id;
|
|
696
|
+
return (
|
|
697
|
+
<motion.button
|
|
698
|
+
key={alt.id}
|
|
699
|
+
whileHover={{ scale: 1.01 }}
|
|
700
|
+
whileTap={{ scale: 0.99 }}
|
|
701
|
+
onClick={() =>
|
|
702
|
+
selectAnswer(currentQuestion.id, alt.id)
|
|
703
|
+
}
|
|
704
|
+
className={`flex items-center gap-3 rounded-xl border p-4 text-left text-sm transition-all ${
|
|
705
|
+
isSelected
|
|
706
|
+
? 'border-primary bg-primary/5 font-medium'
|
|
707
|
+
: 'border-border hover:border-primary/30 hover:bg-muted/50'
|
|
708
|
+
}`}
|
|
709
|
+
>
|
|
710
|
+
<span
|
|
711
|
+
className={`flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium transition-colors ${
|
|
712
|
+
isSelected
|
|
713
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
714
|
+
: 'border-border'
|
|
715
|
+
}`}
|
|
716
|
+
>
|
|
717
|
+
{String.fromCharCode(65 + i)}
|
|
718
|
+
</span>
|
|
719
|
+
<span className="flex-1">{alt.texto}</span>
|
|
720
|
+
{isSelected && (
|
|
721
|
+
<CircleDot className="size-5 shrink-0" />
|
|
722
|
+
)}
|
|
723
|
+
</motion.button>
|
|
724
|
+
);
|
|
725
|
+
})}
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
{/* Navigation */}
|
|
729
|
+
<div className="mt-8 flex items-center justify-between">
|
|
730
|
+
<Button
|
|
731
|
+
variant="outline"
|
|
732
|
+
className="gap-2"
|
|
733
|
+
disabled={currentIndex === 0}
|
|
734
|
+
onClick={() => setCurrentIndex((p) => p - 1)}
|
|
735
|
+
>
|
|
736
|
+
<ChevronLeft className="size-4" />{' '}
|
|
737
|
+
{t('examInProgress.previousButton')}
|
|
738
|
+
</Button>
|
|
739
|
+
<Button
|
|
740
|
+
variant="ghost"
|
|
741
|
+
className={`shrink-0 ${flagged.has(currentQuestion.id) ? 'text-primary' : 'text-muted-foreground'}`}
|
|
742
|
+
className="lg:hidden"
|
|
743
|
+
onClick={() => setShowNav(!showNav)}
|
|
744
|
+
>
|
|
745
|
+
{currentIndex + 1}/{examQuestions.length}
|
|
746
|
+
</Button>
|
|
747
|
+
{currentIndex < examQuestions.length - 1 ? (
|
|
748
|
+
<Button
|
|
749
|
+
className="gap-2"
|
|
750
|
+
onClick={() => setCurrentIndex((p) => p + 1)}
|
|
751
|
+
>
|
|
752
|
+
{t('examInProgress.nextButton')}{' '}
|
|
753
|
+
<ChevronRight className="size-4" />
|
|
754
|
+
</Button>
|
|
755
|
+
) : (
|
|
756
|
+
<Button
|
|
757
|
+
className="gap-2"
|
|
758
|
+
onClick={() => setSubmitDialogOpen(true)}
|
|
759
|
+
>
|
|
760
|
+
<Send className="size-4" />{' '}
|
|
761
|
+
{t('examInProgress.finishButton')}
|
|
762
|
+
</Button>
|
|
763
|
+
)}
|
|
764
|
+
</div>
|
|
765
|
+
</CardContent>
|
|
766
|
+
</Card>
|
|
767
|
+
</motion.div>
|
|
768
|
+
</AnimatePresence>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
{/* Side navigation - desktop */}
|
|
772
|
+
<div className="hidden w-64 shrink-0 lg:block">
|
|
773
|
+
<Card className="sticky top-20">
|
|
774
|
+
<CardHeader className="pb-3">
|
|
775
|
+
<CardTitle className="text-sm font-medium">
|
|
776
|
+
{t('examInProgress.navigation')}
|
|
777
|
+
</CardTitle>
|
|
778
|
+
<p className="text-xs text-muted-foreground">
|
|
779
|
+
{t('examInProgress.answeredCount', {
|
|
780
|
+
count: answeredCount,
|
|
781
|
+
total: examQuestions.length,
|
|
782
|
+
})}
|
|
783
|
+
</p>
|
|
784
|
+
</CardHeader>
|
|
785
|
+
<CardContent className="pb-4">
|
|
786
|
+
<div className="grid grid-cols-5 gap-2">
|
|
787
|
+
{examQuestions.map((q, i) => {
|
|
788
|
+
const isAnswered = !!answers[q.id];
|
|
789
|
+
const isFlagged = flagged.has(q.id);
|
|
790
|
+
const isCurrent = i === currentIndex;
|
|
791
|
+
return (
|
|
792
|
+
<button
|
|
793
|
+
key={q.id}
|
|
794
|
+
onClick={() => goToQuestion(i)}
|
|
795
|
+
className={`relative flex size-10 items-center justify-center rounded-lg text-sm font-medium transition-all ${
|
|
796
|
+
isCurrent
|
|
797
|
+
? 'bg-primary text-primary-foreground ring-1 ring-primary ring-offset-2'
|
|
798
|
+
: isAnswered
|
|
799
|
+
? 'bg-primary/10 text-primary'
|
|
800
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
|
801
|
+
}`}
|
|
802
|
+
aria-label={`${t('examInProgress.questionLabel', { number: i + 1 })}${isAnswered ? ` ${t('examInProgress.questionAnswered')}` : ''}${isFlagged ? ` ${t('examInProgress.questionMarked')}` : ''}`}
|
|
803
|
+
>
|
|
804
|
+
{i + 1}
|
|
805
|
+
{isFlagged && (
|
|
806
|
+
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-primary" />
|
|
807
|
+
)}
|
|
808
|
+
</button>
|
|
809
|
+
);
|
|
810
|
+
})}
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<div className="mt-4 flex flex-col gap-2 text-xs text-muted-foreground">
|
|
814
|
+
<div className="flex items-center gap-2">
|
|
815
|
+
<span className="flex size-4 items-center justify-center rounded bg-primary text-primary-foreground text-[10px]">
|
|
816
|
+
|
|
817
|
+
</span>
|
|
818
|
+
{t('examInProgress.current')}
|
|
819
|
+
</div>
|
|
820
|
+
<div className="flex items-center gap-2">
|
|
821
|
+
<span className="flex size-4 items-center justify-center rounded bg-primary/10">
|
|
822
|
+
|
|
823
|
+
</span>
|
|
824
|
+
{t('examInProgress.answeredLabel')}
|
|
825
|
+
</div>
|
|
826
|
+
<div className="flex items-center gap-2">
|
|
827
|
+
<span className="flex size-4 items-center justify-center rounded bg-muted">
|
|
828
|
+
|
|
829
|
+
</span>
|
|
830
|
+
{t('examInProgress.notAnswered')}
|
|
831
|
+
</div>
|
|
832
|
+
<div className="flex items-center gap-2">
|
|
833
|
+
<span className="relative flex size-4 items-center justify-center rounded bg-muted">
|
|
834
|
+
<span className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-primary" />
|
|
835
|
+
</span>
|
|
836
|
+
{t('examInProgress.markedForReview')}
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
</CardContent>
|
|
840
|
+
</Card>
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
{/* Mobile nav overlay */}
|
|
845
|
+
<AnimatePresence>
|
|
846
|
+
{showNav && (
|
|
847
|
+
<motion.div
|
|
848
|
+
initial={{ opacity: 0 }}
|
|
849
|
+
animate={{ opacity: 1 }}
|
|
850
|
+
exit={{ opacity: 0 }}
|
|
851
|
+
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm lg:hidden"
|
|
852
|
+
onClick={() => setShowNav(false)}
|
|
853
|
+
>
|
|
854
|
+
<motion.div
|
|
855
|
+
initial={{ y: '100%' }}
|
|
856
|
+
animate={{ y: 0 }}
|
|
857
|
+
exit={{ y: '100%' }}
|
|
858
|
+
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
|
859
|
+
className="absolute bottom-0 left-0 right-0 rounded-t-2xl border-t bg-background p-6"
|
|
860
|
+
onClick={(e) => e.stopPropagation()}
|
|
861
|
+
>
|
|
862
|
+
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-muted" />
|
|
863
|
+
<p className="mb-3 text-sm font-medium">
|
|
864
|
+
{t('examInProgress.navigationMobile', {
|
|
865
|
+
count: answeredCount,
|
|
866
|
+
total: examQuestions.length,
|
|
867
|
+
})}
|
|
868
|
+
</p>
|
|
869
|
+
<div className="grid grid-cols-8 gap-2">
|
|
870
|
+
{examQuestions.map((q, i) => {
|
|
871
|
+
const isAnswered = !!answers[q.id];
|
|
872
|
+
const isFlagged = flagged.has(q.id);
|
|
873
|
+
const isCurrent = i === currentIndex;
|
|
874
|
+
return (
|
|
875
|
+
<button
|
|
876
|
+
key={q.id}
|
|
877
|
+
onClick={() => goToQuestion(i)}
|
|
878
|
+
className={`relative flex size-10 items-center justify-center rounded-lg text-sm font-medium transition-all ${
|
|
879
|
+
isCurrent
|
|
880
|
+
? 'bg-primary text-primary-foreground'
|
|
881
|
+
: isAnswered
|
|
882
|
+
? 'bg-primary/10 text-primary'
|
|
883
|
+
: 'bg-muted text-muted-foreground'
|
|
884
|
+
}`}
|
|
885
|
+
>
|
|
886
|
+
{i + 1}
|
|
887
|
+
{isFlagged && (
|
|
888
|
+
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-primary" />
|
|
889
|
+
)}
|
|
890
|
+
</button>
|
|
891
|
+
);
|
|
892
|
+
})}
|
|
893
|
+
</div>
|
|
894
|
+
<Button className="mt-4 w-full" onClick={() => setShowNav(false)}>
|
|
895
|
+
{t('examInProgress.closeButton')}
|
|
896
|
+
</Button>
|
|
897
|
+
</motion.div>
|
|
898
|
+
</motion.div>
|
|
899
|
+
)}
|
|
900
|
+
</AnimatePresence>
|
|
901
|
+
|
|
902
|
+
{/* Submit Dialog */}
|
|
903
|
+
<Dialog open={submitDialogOpen} onOpenChange={setSubmitDialogOpen}>
|
|
904
|
+
<DialogContent className="max-w-3xl">
|
|
905
|
+
<DialogHeader>
|
|
906
|
+
<DialogTitle className="flex items-center gap-2">
|
|
907
|
+
<BookmarkCheck className="size-5" />
|
|
908
|
+
{t('submitDialog.title')}
|
|
909
|
+
</DialogTitle>
|
|
910
|
+
<DialogDescription asChild>
|
|
911
|
+
<div>
|
|
912
|
+
<p>{t('submitDialog.description')}</p>
|
|
913
|
+
<div className="mt-3 flex gap-4 rounded-lg bg-muted p-3 text-sm">
|
|
914
|
+
<div>
|
|
915
|
+
<p className="font-medium text-primary">
|
|
916
|
+
{answeredCount}/{examQuestions.length}
|
|
917
|
+
</p>
|
|
918
|
+
<p className="text-xs">{t('submitDialog.answeredLabel')}</p>
|
|
919
|
+
</div>
|
|
920
|
+
<div>
|
|
921
|
+
<p className="font-medium text-primary">
|
|
922
|
+
{examQuestions.length - answeredCount}
|
|
923
|
+
</p>
|
|
924
|
+
<p className="text-xs">{t('submitDialog.blankLabel')}</p>
|
|
925
|
+
</div>
|
|
926
|
+
<div>
|
|
927
|
+
<p className="font-medium text-primary">{flagged.size}</p>
|
|
928
|
+
<p className="text-xs">{t('submitDialog.markedLabel')}</p>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
{examQuestions.length - answeredCount > 0 && (
|
|
932
|
+
<p className="mt-2 text-sm text-destructive">
|
|
933
|
+
{t('submitDialog.warning', {
|
|
934
|
+
count: examQuestions.length - answeredCount,
|
|
935
|
+
})}
|
|
936
|
+
</p>
|
|
937
|
+
)}
|
|
938
|
+
</div>
|
|
939
|
+
</DialogDescription>
|
|
940
|
+
</DialogHeader>
|
|
941
|
+
<DialogFooter>
|
|
942
|
+
<Button
|
|
943
|
+
variant="outline"
|
|
944
|
+
onClick={() => setSubmitDialogOpen(false)}
|
|
945
|
+
>
|
|
946
|
+
{t('submitDialog.continueButton')}
|
|
947
|
+
</Button>
|
|
948
|
+
<Button onClick={handleSubmit}>
|
|
949
|
+
{t('submitDialog.submitButton')}
|
|
950
|
+
</Button>
|
|
951
|
+
</DialogFooter>
|
|
952
|
+
</DialogContent>
|
|
953
|
+
</Dialog>
|
|
954
|
+
|
|
955
|
+
{/* Time Up Dialog */}
|
|
956
|
+
<Dialog open={timeUpDialogOpen} onOpenChange={() => {}}>
|
|
957
|
+
<DialogContent className="[&>button]:hidden">
|
|
958
|
+
<DialogHeader>
|
|
959
|
+
<DialogTitle className="flex items-center gap-2">
|
|
960
|
+
<AlertTriangle className="size-5 text-destructive" />
|
|
961
|
+
{t('timeUpDialog.title')}
|
|
962
|
+
</DialogTitle>
|
|
963
|
+
<DialogDescription>
|
|
964
|
+
{t('timeUpDialog.description')}
|
|
965
|
+
</DialogDescription>
|
|
966
|
+
</DialogHeader>
|
|
967
|
+
<DialogFooter>
|
|
968
|
+
<Button onClick={handleForceSubmit}>
|
|
969
|
+
{t('timeUpDialog.viewResultButton')}
|
|
970
|
+
</Button>
|
|
971
|
+
</DialogFooter>
|
|
972
|
+
</DialogContent>
|
|
973
|
+
</Dialog>
|
|
974
|
+
</Page>
|
|
975
|
+
);
|
|
976
|
+
}
|