@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,931 @@
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 } 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 { Field, FieldLabel } from '@/components/ui/field';
16
+ import { Input } from '@/components/ui/input';
17
+ import { Separator } from '@/components/ui/separator';
18
+ import {
19
+ Sheet,
20
+ SheetContent,
21
+ SheetDescription,
22
+ SheetFooter,
23
+ SheetHeader,
24
+ SheetTitle,
25
+ } from '@/components/ui/sheet';
26
+ import { Skeleton } from '@/components/ui/skeleton';
27
+ import { Textarea } from '@/components/ui/textarea';
28
+ import {
29
+ closestCenter,
30
+ DndContext,
31
+ KeyboardSensor,
32
+ PointerSensor,
33
+ useSensor,
34
+ useSensors,
35
+ type DragEndEvent,
36
+ } from '@dnd-kit/core';
37
+ import {
38
+ arrayMove,
39
+ SortableContext,
40
+ sortableKeyboardCoordinates,
41
+ useSortable,
42
+ verticalListSortingStrategy,
43
+ } from '@dnd-kit/sortable';
44
+ import { CSS } from '@dnd-kit/utilities';
45
+ import { zodResolver } from '@hookform/resolvers/zod';
46
+ import { AnimatePresence, motion } from 'framer-motion';
47
+ import {
48
+ AlertTriangle,
49
+ Check,
50
+ ChevronDown,
51
+ ChevronUp,
52
+ CircleDot,
53
+ CircleOff,
54
+ Copy,
55
+ FileCheck,
56
+ GripVertical,
57
+ Pencil,
58
+ Plus,
59
+ Save,
60
+ Trash2,
61
+ X,
62
+ } from 'lucide-react';
63
+ import { useTranslations } from 'next-intl';
64
+ import { useParams, usePathname } from 'next/navigation';
65
+ import { useEffect, useState } from 'react';
66
+ import { useForm } from 'react-hook-form';
67
+ import { toast } from 'sonner';
68
+ import { z } from 'zod';
69
+
70
+ interface Alternativa {
71
+ id: string;
72
+ texto: string;
73
+ correta: boolean;
74
+ }
75
+
76
+ interface Questao {
77
+ id: string;
78
+ enunciado: string;
79
+ pontuacao: number;
80
+ alternativas: Alternativa[];
81
+ }
82
+
83
+ type QuestaoForm = {
84
+ enunciado: string;
85
+ pontuacao: number;
86
+ };
87
+
88
+ function generateId() {
89
+ return Math.random().toString(36).substring(2, 9);
90
+ }
91
+
92
+ const initialQuestoes: Questao[] = [
93
+ {
94
+ id: 'q1',
95
+ enunciado:
96
+ 'Qual hook do React e utilizado para gerenciar estado local em componentes funcionais?',
97
+ pontuacao: 2,
98
+ alternativas: [
99
+ { id: 'a1', texto: 'useEffect', correta: false },
100
+ { id: 'a2', texto: 'useState', correta: true },
101
+ { id: 'a3', texto: 'useContext', correta: false },
102
+ { id: 'a4', texto: 'useReducer', correta: false },
103
+ ],
104
+ },
105
+ {
106
+ id: 'q2',
107
+ enunciado: 'O que o Virtual DOM faz no React?',
108
+ pontuacao: 2,
109
+ alternativas: [
110
+ { id: 'a5', texto: 'Substitui o DOM real completamente', correta: false },
111
+ {
112
+ id: 'a6',
113
+ texto:
114
+ 'Cria uma copia do DOM para comparar e atualizar apenas o necessario',
115
+ correta: true,
116
+ },
117
+ { id: 'a7', texto: 'Elimina a necessidade de HTML', correta: false },
118
+ ],
119
+ },
120
+ {
121
+ id: 'q3',
122
+ enunciado: 'Qual e a principal diferenca entre props e state?',
123
+ pontuacao: 3,
124
+ alternativas: [
125
+ {
126
+ id: 'a8',
127
+ texto: 'Props sao mutaveis, state sao imutaveis',
128
+ correta: false,
129
+ },
130
+ {
131
+ id: 'a9',
132
+ texto: 'Props sao passados pelo pai, state e gerenciado internamente',
133
+ correta: true,
134
+ },
135
+ { id: 'a10', texto: 'Nao ha diferenca', correta: false },
136
+ {
137
+ id: 'a11',
138
+ texto: 'State so existe em class components',
139
+ correta: false,
140
+ },
141
+ { id: 'a12', texto: 'Props nao podem ser funcoes', correta: false },
142
+ ],
143
+ },
144
+ {
145
+ id: 'q4',
146
+ enunciado:
147
+ 'Qual metodo do ciclo de vida e equivalente ao useEffect com array de dependencias vazio?',
148
+ pontuacao: 2,
149
+ alternativas: [
150
+ { id: 'a13', texto: 'componentDidMount', correta: true },
151
+ { id: 'a14', texto: 'componentWillUpdate', correta: false },
152
+ { id: 'a15', texto: 'componentDidUpdate', correta: false },
153
+ ],
154
+ },
155
+ {
156
+ id: 'q5',
157
+ enunciado: 'Para que serve o React.memo?',
158
+ pontuacao: 1,
159
+ alternativas: [
160
+ { id: 'a16', texto: 'Para memorizar valores computados', correta: false },
161
+ {
162
+ id: 'a17',
163
+ texto: 'Para evitar re-renderizacoes desnecessarias de componentes',
164
+ correta: true,
165
+ },
166
+ ],
167
+ },
168
+ ];
169
+
170
+ function SortableAlternativa({
171
+ alt,
172
+ index,
173
+ onToggleCorrect,
174
+ onChangeTexto,
175
+ onRemove,
176
+ canRemove,
177
+ }: {
178
+ alt: Alternativa;
179
+ index: number;
180
+ onToggleCorrect: () => void;
181
+ onChangeTexto: (texto: string) => void;
182
+ onRemove: () => void;
183
+ canRemove: boolean;
184
+ }) {
185
+ const t = useTranslations('lms.QuestionsPage');
186
+ const {
187
+ attributes,
188
+ listeners,
189
+ setNodeRef,
190
+ transform,
191
+ transition,
192
+ isDragging,
193
+ } = useSortable({ id: alt.id });
194
+
195
+ const style = {
196
+ transform: CSS.Transform.toString(transform),
197
+ transition,
198
+ };
199
+
200
+ return (
201
+ <div
202
+ ref={setNodeRef}
203
+ style={style}
204
+ className={`flex items-center gap-2 rounded-lg border p-2 transition-colors ${
205
+ isDragging ? 'z-50 bg-muted shadow-lg' : ''
206
+ } ${alt.correta ? 'border-foreground/30 bg-muted/50' : 'bg-background'}`}
207
+ >
208
+ <button
209
+ type="button"
210
+ className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
211
+ {...attributes}
212
+ {...listeners}
213
+ aria-label={t('question.dragAlternative')}
214
+ >
215
+ <GripVertical className="size-4" />
216
+ </button>
217
+
218
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
219
+ {String.fromCharCode(65 + index)}
220
+ </span>
221
+
222
+ <Input
223
+ value={alt.texto}
224
+ onChange={(e) => onChangeTexto(e.target.value)}
225
+ className="flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
226
+ placeholder={t('sheet.fields.alternativePlaceholder')}
227
+ />
228
+
229
+ <button
230
+ type="button"
231
+ onClick={onToggleCorrect}
232
+ className={`shrink-0 rounded-full p-1 transition-colors ${
233
+ alt.correta
234
+ ? 'text-foreground'
235
+ : 'text-muted-foreground hover:text-foreground'
236
+ }`}
237
+ aria-label={
238
+ alt.correta ? t('question.markIncorrect') : t('question.markCorrect')
239
+ }
240
+ >
241
+ {alt.correta ? (
242
+ <CircleDot className="size-5" />
243
+ ) : (
244
+ <CircleOff className="size-5" />
245
+ )}
246
+ </button>
247
+
248
+ {canRemove && (
249
+ <button
250
+ type="button"
251
+ onClick={onRemove}
252
+ className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-destructive"
253
+ aria-label={t('question.removeAlternative')}
254
+ >
255
+ <X className="size-4" />
256
+ </button>
257
+ )}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ function SortableQuestao({
263
+ questao,
264
+ qIndex,
265
+ isExpanded,
266
+ onToggleExpand,
267
+ onEdit,
268
+ onDuplicate,
269
+ onDelete,
270
+ }: {
271
+ questao: Questao;
272
+ qIndex: number;
273
+ isExpanded: boolean;
274
+ onToggleExpand: () => void;
275
+ onEdit: () => void;
276
+ onDuplicate: () => void;
277
+ onDelete: () => void;
278
+ }) {
279
+ const t = useTranslations('lms.QuestionsPage');
280
+ const {
281
+ attributes,
282
+ listeners,
283
+ setNodeRef,
284
+ transform,
285
+ transition,
286
+ isDragging,
287
+ } = useSortable({ id: questao.id });
288
+
289
+ const style = {
290
+ transform: CSS.Transform.toString(transform),
291
+ transition,
292
+ };
293
+
294
+ return (
295
+ <motion.div
296
+ ref={setNodeRef}
297
+ style={style}
298
+ layout
299
+ initial={{ opacity: 0, y: 10 }}
300
+ animate={{ opacity: 1, y: 0 }}
301
+ exit={{ opacity: 0, y: -10 }}
302
+ transition={{ duration: 0.2 }}
303
+ className={isDragging ? 'z-50' : ''}
304
+ >
305
+ <Card className="overflow-hidden">
306
+ {/* Question header */}
307
+ <div className="flex items-start gap-3 p-4 transition-colors hover:bg-muted/30">
308
+ <button
309
+ type="button"
310
+ className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
311
+ {...attributes}
312
+ {...listeners}
313
+ aria-label={t('question.dragQuestion')}
314
+ >
315
+ <GripVertical className="size-5" />
316
+ </button>
317
+
318
+ <div
319
+ className="flex min-w-0 flex-1 cursor-pointer items-start gap-3"
320
+ onClick={onToggleExpand}
321
+ role="button"
322
+ aria-expanded={isExpanded}
323
+ tabIndex={0}
324
+ onKeyDown={(e) => {
325
+ if (e.key === 'Enter' || e.key === ' ') {
326
+ e.preventDefault();
327
+ onToggleExpand();
328
+ }
329
+ }}
330
+ >
331
+ <div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-sm font-bold text-background">
332
+ {qIndex + 1}
333
+ </div>
334
+ <div className="min-w-0 flex-1">
335
+ <p className="text-sm font-medium leading-relaxed">
336
+ {questao.enunciado}
337
+ </p>
338
+ <div className="mt-2 flex flex-wrap items-center gap-2">
339
+ <Badge variant="secondary" className="text-xs">
340
+ {t('question.alternatives', {
341
+ count: questao.alternativas.length,
342
+ })}
343
+ </Badge>
344
+ <Badge variant="outline" className="text-xs">
345
+ {questao.pontuacao === 1
346
+ ? t('question.points', { score: questao.pontuacao })
347
+ : t('question.pointsPlural', { score: questao.pontuacao })}
348
+ </Badge>
349
+ </div>
350
+ </div>
351
+ <div className="shrink-0">
352
+ {isExpanded ? (
353
+ <ChevronUp className="size-4 text-muted-foreground" />
354
+ ) : (
355
+ <ChevronDown className="size-4 text-muted-foreground" />
356
+ )}
357
+ </div>
358
+ </div>
359
+ </div>
360
+
361
+ {/* Expanded content */}
362
+ <AnimatePresence>
363
+ {isExpanded && (
364
+ <motion.div
365
+ initial={{ height: 0, opacity: 0 }}
366
+ animate={{ height: 'auto', opacity: 1 }}
367
+ exit={{ height: 0, opacity: 0 }}
368
+ transition={{ duration: 0.25 }}
369
+ className="overflow-hidden"
370
+ >
371
+ <Separator />
372
+ <div className="p-4">
373
+ <div className="flex flex-col gap-2">
374
+ {questao.alternativas.map((alt, altIndex) => (
375
+ <div
376
+ key={alt.id}
377
+ className={`flex items-center gap-3 rounded-lg border p-3 text-sm ${
378
+ alt.correta
379
+ ? 'border-foreground/20 bg-muted/50 font-medium'
380
+ : ''
381
+ }`}
382
+ >
383
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
384
+ {String.fromCharCode(65 + altIndex)}
385
+ </span>
386
+ <span className="flex-1">{alt.texto}</span>
387
+ {alt.correta && (
388
+ <Check className="size-4 shrink-0 text-foreground" />
389
+ )}
390
+ </div>
391
+ ))}
392
+ </div>
393
+
394
+ <div className="mt-4 flex flex-wrap gap-2">
395
+ <Button
396
+ variant="outline"
397
+ size="sm"
398
+ className="gap-1.5"
399
+ onClick={onEdit}
400
+ >
401
+ <Pencil className="size-3.5" /> {t('question.actions.edit')}
402
+ </Button>
403
+ <Button
404
+ variant="outline"
405
+ size="sm"
406
+ className="gap-1.5"
407
+ onClick={onDuplicate}
408
+ >
409
+ <Copy className="size-3.5" />{' '}
410
+ {t('question.actions.duplicate')}
411
+ </Button>
412
+ <Button
413
+ variant="outline"
414
+ size="sm"
415
+ className="gap-1.5 text-destructive hover:text-destructive"
416
+ onClick={onDelete}
417
+ >
418
+ <Trash2 className="size-3.5" />{' '}
419
+ {t('question.actions.delete')}
420
+ </Button>
421
+ </div>
422
+ </div>
423
+ </motion.div>
424
+ )}
425
+ </AnimatePresence>
426
+ </Card>
427
+ </motion.div>
428
+ );
429
+ }
430
+
431
+ export default function QuestoesPage() {
432
+ const t = useTranslations('lms.QuestionsPage');
433
+ const { id } = useParams();
434
+ const pathname = usePathname();
435
+ const [loading, setLoading] = useState(true);
436
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
437
+ const [questoes, setQuestoes] = useState<Questao[]>(initialQuestoes);
438
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
439
+ const [sheetOpen, setSheetOpen] = useState(false);
440
+ const [editingQuestao, setEditingQuestao] = useState<Questao | null>(null);
441
+ const [sheetAlternativas, setSheetAlternativas] = useState<Alternativa[]>([]);
442
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
443
+ const [questaoToDelete, setQuestaoToDelete] = useState<Questao | null>(null);
444
+ const [hasChanges, setHasChanges] = useState(false);
445
+
446
+ const questaoSchema = z.object({
447
+ enunciado: z.string().min(5, t('sheet.validation.statementMin')),
448
+ pontuacao: z.coerce.number().min(0.5, t('sheet.validation.scoreMin')),
449
+ });
450
+
451
+ const form = useForm<QuestaoForm>({
452
+ resolver: zodResolver(questaoSchema),
453
+ defaultValues: { enunciado: '', pontuacao: 2 },
454
+ });
455
+
456
+ const sensors = useSensors(
457
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
458
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
459
+ );
460
+
461
+ useEffect(() => {
462
+ const timer = setTimeout(() => setLoading(false), 600);
463
+ return () => clearTimeout(timer);
464
+ }, []);
465
+
466
+ function toggleExpand(qId: string) {
467
+ setExpandedIds((prev) => {
468
+ const next = new Set(prev);
469
+ if (next.has(qId)) next.delete(qId);
470
+ else next.add(qId);
471
+ return next;
472
+ });
473
+ }
474
+
475
+ function openCreateSheet() {
476
+ setEditingQuestao(null);
477
+ form.reset({ enunciado: '', pontuacao: 2 });
478
+ setSheetAlternativas([
479
+ { id: generateId(), texto: '', correta: true },
480
+ { id: generateId(), texto: '', correta: false },
481
+ ]);
482
+ setSheetOpen(true);
483
+ }
484
+
485
+ function openEditSheet(questao: Questao) {
486
+ setEditingQuestao(questao);
487
+ form.reset({ enunciado: questao.enunciado, pontuacao: questao.pontuacao });
488
+ setSheetAlternativas(questao.alternativas.map((a) => ({ ...a })));
489
+ setSheetOpen(true);
490
+ }
491
+
492
+ function addAlternativa() {
493
+ setSheetAlternativas((prev) => [
494
+ ...prev,
495
+ { id: generateId(), texto: '', correta: false },
496
+ ]);
497
+ }
498
+
499
+ function removeAlternativa(altId: string) {
500
+ setSheetAlternativas((prev) => prev.filter((a) => a.id !== altId));
501
+ }
502
+
503
+ function toggleCorreta(altId: string) {
504
+ setSheetAlternativas((prev) =>
505
+ prev.map((a) => ({ ...a, correta: a.id === altId }))
506
+ );
507
+ }
508
+
509
+ function updateAlternativaTexto(altId: string, texto: string) {
510
+ setSheetAlternativas((prev) =>
511
+ prev.map((a) => (a.id === altId ? { ...a, texto } : a))
512
+ );
513
+ }
514
+
515
+ function handleAltDragEnd(event: DragEndEvent) {
516
+ const { active, over } = event;
517
+ if (over && active.id !== over.id) {
518
+ setSheetAlternativas((items) => {
519
+ const oldIndex = items.findIndex((i) => i.id === active.id);
520
+ const newIndex = items.findIndex((i) => i.id === over.id);
521
+ return arrayMove(items, oldIndex, newIndex);
522
+ });
523
+ }
524
+ }
525
+
526
+ function onSubmit(data: QuestaoForm) {
527
+ const hasCorreta = sheetAlternativas.some((a) => a.correta);
528
+ const allHaveText = sheetAlternativas.every(
529
+ (a) => a.texto.trim().length > 0
530
+ );
531
+
532
+ if (!hasCorreta) {
533
+ toast.error(t('toasts.noCorrectAnswer'));
534
+ return;
535
+ }
536
+ if (!allHaveText) {
537
+ toast.error(t('toasts.allMustHaveText'));
538
+ return;
539
+ }
540
+ if (sheetAlternativas.length < 2) {
541
+ toast.error(t('toasts.minAlternatives'));
542
+ return;
543
+ }
544
+
545
+ if (editingQuestao) {
546
+ setQuestoes((prev) =>
547
+ prev.map((q) =>
548
+ q.id === editingQuestao.id
549
+ ? {
550
+ ...q,
551
+ enunciado: data.enunciado,
552
+ pontuacao: data.pontuacao,
553
+ alternativas: sheetAlternativas,
554
+ }
555
+ : q
556
+ )
557
+ );
558
+ toast.success(t('toasts.questionUpdated'));
559
+ } else {
560
+ const nova: Questao = {
561
+ id: generateId(),
562
+ enunciado: data.enunciado,
563
+ pontuacao: data.pontuacao,
564
+ alternativas: sheetAlternativas,
565
+ };
566
+ setQuestoes((prev) => [...prev, nova]);
567
+ toast.success(t('toasts.questionCreated'));
568
+ }
569
+ setHasChanges(true);
570
+ setSheetOpen(false);
571
+ }
572
+
573
+ function confirmDelete() {
574
+ if (questaoToDelete) {
575
+ setQuestoes((prev) => prev.filter((q) => q.id !== questaoToDelete.id));
576
+ setHasChanges(true);
577
+ toast.success(t('toasts.questionRemoved'));
578
+ setQuestaoToDelete(null);
579
+ setDeleteDialogOpen(false);
580
+ }
581
+ }
582
+
583
+ function duplicateQuestao(questao: Questao) {
584
+ const nova: Questao = {
585
+ ...questao,
586
+ id: generateId(),
587
+ enunciado: `${questao.enunciado}${t('copySuffix')}`,
588
+ alternativas: questao.alternativas.map((a) => ({
589
+ ...a,
590
+ id: generateId(),
591
+ })),
592
+ };
593
+ setQuestoes((prev) => {
594
+ const idx = prev.findIndex((q) => q.id === questao.id);
595
+ const next = [...prev];
596
+ next.splice(idx + 1, 0, nova);
597
+ return next;
598
+ });
599
+ setHasChanges(true);
600
+ toast.success(t('toasts.questionDuplicated'));
601
+ }
602
+
603
+ function handleQuestionDragEnd(event: DragEndEvent) {
604
+ const { active, over } = event;
605
+ if (over && active.id !== over.id) {
606
+ setQuestoes((items) => {
607
+ const oldIndex = items.findIndex((i) => i.id === active.id);
608
+ const newIndex = items.findIndex((i) => i.id === over.id);
609
+ return arrayMove(items, oldIndex, newIndex);
610
+ });
611
+ setHasChanges(true);
612
+ }
613
+ }
614
+
615
+ function handleSave() {
616
+ setHasChanges(false);
617
+ toast.success(t('toasts.changesSaved'));
618
+ }
619
+
620
+ const totalPontuacao = questoes.reduce((a, q) => a + q.pontuacao, 0);
621
+
622
+ return (
623
+ <Page>
624
+ <PageHeader
625
+ title={t('title')}
626
+ description={t('description', {
627
+ code: 'EX-001',
628
+ count: questoes.length,
629
+ total: totalPontuacao,
630
+ })}
631
+ breadcrumbs={[
632
+ {
633
+ label: t('breadcrumbs.home'),
634
+ href: '/',
635
+ },
636
+ {
637
+ label: t('breadcrumbs.exams'),
638
+ href: '/lms/exams',
639
+ },
640
+ {
641
+ label: t('breadcrumbs.questions'),
642
+ },
643
+ ]}
644
+ actions={
645
+ <div className="flex items-center gap-2">
646
+ <AnimatePresence>
647
+ {hasChanges && (
648
+ <motion.div
649
+ initial={{ opacity: 0, scale: 0.9 }}
650
+ animate={{ opacity: 1, scale: 1 }}
651
+ exit={{ opacity: 0, scale: 0.9 }}
652
+ >
653
+ <Button
654
+ onClick={handleSave}
655
+ className="gap-2"
656
+ variant="outline"
657
+ >
658
+ <Save className="size-4" /> {t('actions.save')}
659
+ </Button>
660
+ </motion.div>
661
+ )}
662
+ </AnimatePresence>
663
+ <Button onClick={openCreateSheet} className="gap-2">
664
+ <Plus className="size-4" /> {t('actions.newQuestion')}
665
+ </Button>
666
+ </div>
667
+ }
668
+ />
669
+
670
+ <div className="pb-10">
671
+ {/* Breadcrumb + actions */}
672
+ <motion.div
673
+ initial={{ opacity: 0, y: 20 }}
674
+ animate={{ opacity: 1, y: 0 }}
675
+ transition={{ duration: 0.4 }}
676
+ >
677
+ {/* Stats */}
678
+ {!loading && (
679
+ <div className="mb-6 grid grid-cols-3 gap-4">
680
+ {[
681
+ { label: t('stats.questions'), valor: questoes.length },
682
+ { label: t('stats.totalScore'), valor: totalPontuacao },
683
+ {
684
+ label: t('stats.avgAlternatives'),
685
+ valor:
686
+ questoes.length > 0
687
+ ? (
688
+ questoes.reduce(
689
+ (a, q) => a + q.alternativas.length,
690
+ 0
691
+ ) / questoes.length
692
+ ).toFixed(1)
693
+ : '0',
694
+ },
695
+ ].map((stat, i) => (
696
+ <motion.div
697
+ key={stat.label}
698
+ initial={{ opacity: 0, y: 10 }}
699
+ animate={{ opacity: 1, y: 0 }}
700
+ transition={{ delay: i * 0.08 }}
701
+ >
702
+ <Card>
703
+ <CardContent className="p-4 text-center">
704
+ <p className="text-2xl font-bold">{stat.valor}</p>
705
+ <p className="text-xs text-muted-foreground">
706
+ {stat.label}
707
+ </p>
708
+ </CardContent>
709
+ </Card>
710
+ </motion.div>
711
+ ))}
712
+ </div>
713
+ )}
714
+
715
+ {/* Questions list */}
716
+ {loading ? (
717
+ <div className="flex flex-col gap-4">
718
+ {Array.from({ length: 4 }).map((_, i) => (
719
+ <Card key={i}>
720
+ <CardContent className="p-4">
721
+ <div className="flex items-center gap-3">
722
+ <Skeleton className="size-8 rounded-full" />
723
+ <div className="flex-1">
724
+ <Skeleton className="mb-2 h-5 w-3/4" />
725
+ <Skeleton className="h-4 w-1/4" />
726
+ </div>
727
+ </div>
728
+ </CardContent>
729
+ </Card>
730
+ ))}
731
+ </div>
732
+ ) : questoes.length === 0 ? (
733
+ <Card>
734
+ <CardContent className="flex flex-col items-center gap-3 py-16">
735
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
736
+ <FileCheck className="size-6 text-muted-foreground" />
737
+ </div>
738
+ <p className="font-medium">{t('empty.title')}</p>
739
+ <p className="text-sm text-muted-foreground">
740
+ {t('empty.description')}
741
+ </p>
742
+ <Button onClick={openCreateSheet} className="mt-2 gap-2">
743
+ <Plus className="size-4" /> {t('empty.action')}
744
+ </Button>
745
+ </CardContent>
746
+ </Card>
747
+ ) : (
748
+ <DndContext
749
+ sensors={sensors}
750
+ collisionDetection={closestCenter}
751
+ onDragEnd={handleQuestionDragEnd}
752
+ >
753
+ <SortableContext
754
+ items={questoes.map((q) => q.id)}
755
+ strategy={verticalListSortingStrategy}
756
+ >
757
+ <div className="flex flex-col gap-3">
758
+ <AnimatePresence>
759
+ {questoes.map((questao, qIndex) => {
760
+ const isExpanded = expandedIds.has(questao.id);
761
+ return (
762
+ <SortableQuestao
763
+ key={questao.id}
764
+ questao={questao}
765
+ qIndex={qIndex}
766
+ isExpanded={isExpanded}
767
+ onToggleExpand={() => toggleExpand(questao.id)}
768
+ onEdit={() => openEditSheet(questao)}
769
+ onDuplicate={() => duplicateQuestao(questao)}
770
+ onDelete={() => {
771
+ setQuestaoToDelete(questao);
772
+ setDeleteDialogOpen(true);
773
+ }}
774
+ />
775
+ );
776
+ })}
777
+ </AnimatePresence>
778
+ </div>
779
+ </SortableContext>
780
+ </DndContext>
781
+ )}
782
+ </motion.div>
783
+ </div>
784
+
785
+ {/* Create/Edit Sheet */}
786
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
787
+ <SheetContent className="overflow-y-auto sm:max-w-xl">
788
+ <SheetHeader>
789
+ <SheetTitle>
790
+ {editingQuestao ? t('sheet.titleEdit') : t('sheet.titleCreate')}
791
+ </SheetTitle>
792
+ <SheetDescription>
793
+ {editingQuestao
794
+ ? t('sheet.descriptionEdit')
795
+ : t('sheet.descriptionCreate')}
796
+ </SheetDescription>
797
+ </SheetHeader>
798
+ <form
799
+ onSubmit={form.handleSubmit(onSubmit)}
800
+ className="flex flex-col gap-5 px-4 pb-4"
801
+ >
802
+ <Field data-invalid={!!form.formState.errors.enunciado}>
803
+ <FieldLabel htmlFor="enunciado">
804
+ {t('sheet.fields.statement')}
805
+ </FieldLabel>
806
+ <Textarea
807
+ id="enunciado"
808
+ rows={3}
809
+ placeholder={t('sheet.fields.statementPlaceholder')}
810
+ {...form.register('enunciado')}
811
+ />
812
+ {form.formState.errors.enunciado && (
813
+ <p className="text-sm text-destructive">
814
+ {form.formState.errors.enunciado.message}
815
+ </p>
816
+ )}
817
+ </Field>
818
+
819
+ <Field data-invalid={!!form.formState.errors.pontuacao}>
820
+ <FieldLabel htmlFor="pontuacao">
821
+ {t('sheet.fields.score')}
822
+ </FieldLabel>
823
+ <Input
824
+ id="pontuacao"
825
+ type="number"
826
+ step="0.5"
827
+ placeholder={t('sheet.fields.scorePlaceholder')}
828
+ {...form.register('pontuacao')}
829
+ />
830
+ {form.formState.errors.pontuacao && (
831
+ <p className="text-sm text-destructive">
832
+ {form.formState.errors.pontuacao.message}
833
+ </p>
834
+ )}
835
+ </Field>
836
+
837
+ <Separator />
838
+
839
+ {/* Alternatives with dnd */}
840
+ <div>
841
+ <div className="mb-3 flex items-center justify-between">
842
+ <div>
843
+ <p className="text-sm font-medium">
844
+ {t('sheet.fields.alternatives')}
845
+ </p>
846
+ <p className="text-xs text-muted-foreground">
847
+ {t('sheet.fields.alternativesDescription')}
848
+ </p>
849
+ </div>
850
+ <Button
851
+ type="button"
852
+ variant="outline"
853
+ size="sm"
854
+ className="gap-1.5"
855
+ onClick={addAlternativa}
856
+ >
857
+ <Plus className="size-3.5" /> {t('sheet.actions.add')}
858
+ </Button>
859
+ </div>
860
+
861
+ <DndContext
862
+ sensors={sensors}
863
+ collisionDetection={closestCenter}
864
+ onDragEnd={handleAltDragEnd}
865
+ >
866
+ <SortableContext
867
+ items={sheetAlternativas.map((a) => a.id)}
868
+ strategy={verticalListSortingStrategy}
869
+ >
870
+ <div className="flex flex-col gap-2">
871
+ {sheetAlternativas.map((alt, i) => (
872
+ <SortableAlternativa
873
+ key={alt.id}
874
+ alt={alt}
875
+ index={i}
876
+ onToggleCorrect={() => toggleCorreta(alt.id)}
877
+ onChangeTexto={(t) => updateAlternativaTexto(alt.id, t)}
878
+ onRemove={() => removeAlternativa(alt.id)}
879
+ canRemove={sheetAlternativas.length > 2}
880
+ />
881
+ ))}
882
+ </div>
883
+ </SortableContext>
884
+ </DndContext>
885
+
886
+ {sheetAlternativas.length < 2 && (
887
+ <p className="mt-2 text-sm text-destructive">
888
+ {t('sheet.validation.minAlternatives')}
889
+ </p>
890
+ )}
891
+ </div>
892
+
893
+ <SheetFooter className="gap-2 p-0 pt-4">
894
+ <Button type="submit">
895
+ {editingQuestao
896
+ ? t('sheet.actions.update')
897
+ : t('sheet.actions.create')}
898
+ </Button>
899
+ </SheetFooter>
900
+ </form>
901
+ </SheetContent>
902
+ </Sheet>
903
+
904
+ {/* Delete Dialog */}
905
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
906
+ <DialogContent>
907
+ <DialogHeader>
908
+ <DialogTitle className="flex items-center gap-2">
909
+ <AlertTriangle className="size-5 text-destructive" />
910
+ {t('deleteDialog.title')}
911
+ </DialogTitle>
912
+ <DialogDescription>
913
+ {t('deleteDialog.description')}
914
+ </DialogDescription>
915
+ </DialogHeader>
916
+ <DialogFooter>
917
+ <Button
918
+ variant="outline"
919
+ onClick={() => setDeleteDialogOpen(false)}
920
+ >
921
+ {t('deleteDialog.actions.cancel')}
922
+ </Button>
923
+ <Button variant="destructive" onClick={confirmDelete}>
924
+ {t('deleteDialog.actions.confirm')}
925
+ </Button>
926
+ </DialogFooter>
927
+ </DialogContent>
928
+ </Dialog>
929
+ </Page>
930
+ );
931
+ }