@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.
Files changed (52) hide show
  1. package/hedhog/data/menu.yaml +8 -1
  2. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1387 -0
  3. package/hedhog/frontend/app/classes/page.tsx.ejs +4 -4
  4. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +1237 -0
  5. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +2642 -0
  6. package/hedhog/frontend/app/courses/page.tsx.ejs +825 -727
  7. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +976 -0
  8. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +931 -0
  9. package/hedhog/frontend/app/exams/page.tsx.ejs +9 -7
  10. package/hedhog/frontend/app/training/page.tsx.ejs +3 -3
  11. package/hedhog/frontend/messages/en.json +703 -14
  12. package/hedhog/frontend/messages/pt.json +863 -174
  13. package/hedhog/query/triggers.sql +0 -0
  14. package/hedhog/table/certificate.yaml +89 -0
  15. package/hedhog/table/certificate_template.yaml +24 -0
  16. package/hedhog/table/course.yaml +67 -1
  17. package/hedhog/table/course_category.yaml +22 -0
  18. package/hedhog/table/course_class_attendance.yaml +34 -0
  19. package/hedhog/table/course_class_group.yaml +58 -0
  20. package/hedhog/table/course_class_session.yaml +38 -0
  21. package/hedhog/table/course_class_session_instructor.yaml +27 -0
  22. package/hedhog/table/course_enrollment.yaml +45 -0
  23. package/hedhog/table/course_image.yaml +33 -0
  24. package/hedhog/table/course_lesson.yaml +35 -0
  25. package/hedhog/table/course_lesson_file.yaml +23 -0
  26. package/hedhog/table/course_lesson_instructor.yaml +27 -0
  27. package/hedhog/table/course_lesson_progress.yaml +40 -0
  28. package/hedhog/table/course_lesson_question.yaml +24 -0
  29. package/hedhog/table/course_module.yaml +25 -0
  30. package/hedhog/table/course_prerequisite.yaml +48 -0
  31. package/hedhog/table/evaluation_rating.yaml +30 -0
  32. package/hedhog/table/evaluation_topic.yaml +68 -0
  33. package/hedhog/table/exam.yaml +91 -0
  34. package/hedhog/table/exam_answer.yaml +40 -0
  35. package/hedhog/table/exam_attempt.yaml +51 -0
  36. package/hedhog/table/exam_image.yaml +33 -0
  37. package/hedhog/table/exam_option.yaml +25 -0
  38. package/hedhog/table/exam_question.yaml +24 -0
  39. package/hedhog/table/image_type.yaml +28 -0
  40. package/hedhog/table/instructor.yaml +23 -0
  41. package/hedhog/table/learning_path.yaml +49 -0
  42. package/hedhog/table/learning_path_enrollment.yaml +33 -0
  43. package/hedhog/table/learning_path_image.yaml +33 -0
  44. package/hedhog/table/learning_path_step.yaml +43 -0
  45. package/hedhog/table/question.yaml +15 -0
  46. package/package.json +9 -6
  47. package/src/index.ts +1 -1
  48. package/src/lms.module.ts +15 -15
  49. package/hedhog/table/classes.yaml +0 -3
  50. package/hedhog/table/exams.yaml +0 -3
  51. package/hedhog/table/reports.yaml +0 -3
  52. 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
+ &nbsp;
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
+ &nbsp;
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
+ &nbsp;
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
+ }