@hed-hog/lms 0.0.366 → 0.0.370

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 (169) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/course/course-ai-usage.service.d.ts +58 -0
  10. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  11. package/dist/course/course-ai-usage.service.js +176 -0
  12. package/dist/course/course-ai-usage.service.js.map +1 -0
  13. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  14. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  15. package/dist/course/course-audio-transcription.service.js +381 -29
  16. package/dist/course/course-audio-transcription.service.js.map +1 -1
  17. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  18. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  19. package/dist/course/course-export-scorm12.service.js +141 -6
  20. package/dist/course/course-export-scorm12.service.js.map +1 -1
  21. package/dist/course/course-export.service.d.ts.map +1 -1
  22. package/dist/course/course-export.service.js +2 -1
  23. package/dist/course/course-export.service.js.map +1 -1
  24. package/dist/course/course-lesson.controller.d.ts +25 -3
  25. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  26. package/dist/course/course-lesson.controller.js +71 -8
  27. package/dist/course/course-lesson.controller.js.map +1 -1
  28. package/dist/course/course-structure.controller.d.ts +26 -5
  29. package/dist/course/course-structure.controller.d.ts.map +1 -1
  30. package/dist/course/course-structure.controller.js +31 -1
  31. package/dist/course/course-structure.controller.js.map +1 -1
  32. package/dist/course/course-structure.service.d.ts +37 -5
  33. package/dist/course/course-structure.service.d.ts.map +1 -1
  34. package/dist/course/course-structure.service.js +165 -20
  35. package/dist/course/course-structure.service.js.map +1 -1
  36. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  37. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  38. package/dist/course/course-transcription-translation.service.js +227 -0
  39. package/dist/course/course-transcription-translation.service.js.map +1 -0
  40. package/dist/course/course-video-agent-pipeline.service.js +7 -7
  41. package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
  42. package/dist/course/course.module.d.ts.map +1 -1
  43. package/dist/course/course.module.js +4 -0
  44. package/dist/course/course.module.js.map +1 -1
  45. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  46. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  47. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  48. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  49. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  50. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  51. package/dist/course/dto/create-course-export.dto.js +6 -0
  52. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  53. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  54. package/dist/course/lms-bulk-upload-automation.service.js +26 -13
  55. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  56. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  57. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  58. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  59. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  60. package/dist/course/lms-bulk-upload.service.js +48 -29
  61. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  62. package/dist/course/subtitle.util.d.ts +46 -0
  63. package/dist/course/subtitle.util.d.ts.map +1 -0
  64. package/dist/course/subtitle.util.js +206 -0
  65. package/dist/course/subtitle.util.js.map +1 -0
  66. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  67. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  68. package/dist/enterprise/training/training-student.service.js +197 -10
  69. package/dist/enterprise/training/training-student.service.js.map +1 -1
  70. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  71. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  72. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  73. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  74. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  75. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  76. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  77. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  78. package/dist/lms.module.d.ts.map +1 -1
  79. package/dist/lms.module.js +4 -0
  80. package/dist/lms.module.js.map +1 -1
  81. package/dist/platforma/platforma-performance.service.js +121 -121
  82. package/dist/platforma/platforma-video.service.d.ts +8 -0
  83. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  84. package/dist/platforma/platforma-video.service.js +45 -2
  85. package/dist/platforma/platforma-video.service.js.map +1 -1
  86. package/dist/platforma/platforma.controller.d.ts +99 -1
  87. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  88. package/dist/platforma/platforma.controller.js +111 -2
  89. package/dist/platforma/platforma.controller.js.map +1 -1
  90. package/dist/training/dto/create-training.dto.d.ts +9 -0
  91. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  92. package/dist/training/dto/create-training.dto.js +45 -1
  93. package/dist/training/dto/create-training.dto.js.map +1 -1
  94. package/dist/training/training.controller.d.ts +144 -0
  95. package/dist/training/training.controller.d.ts.map +1 -1
  96. package/dist/training/training.service.d.ts +149 -0
  97. package/dist/training/training.service.d.ts.map +1 -1
  98. package/dist/training/training.service.js +332 -167
  99. package/dist/training/training.service.js.map +1 -1
  100. package/hedhog/data/image_type.yaml +10 -0
  101. package/hedhog/data/route.yaml +251 -0
  102. package/hedhog/data/setting_group.yaml +97 -0
  103. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  104. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  105. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  106. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  107. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  108. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  110. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  111. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  112. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  113. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  114. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  115. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  116. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  117. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  118. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  119. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  120. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  121. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  122. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  123. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  124. package/hedhog/frontend/app/courses/page.tsx.ejs +26 -4
  125. package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
  126. package/hedhog/frontend/messages/en.json +23 -12
  127. package/hedhog/frontend/messages/pt.json +23 -12
  128. package/hedhog/query/triggers.sql +33 -0
  129. package/hedhog/table/course_ai_usage.yaml +46 -0
  130. package/hedhog/table/course_lesson.yaml +3 -0
  131. package/hedhog/table/course_lesson_answer.yaml +37 -0
  132. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  133. package/hedhog/table/learning_path.yaml +6 -0
  134. package/hedhog/table/learning_path_module.yaml +22 -0
  135. package/hedhog/table/learning_path_step.yaml +9 -6
  136. package/hedhog/table/lesson_view_event.yaml +66 -66
  137. package/package.json +9 -9
  138. package/src/certificate/certificate.controller.ts +2 -0
  139. package/src/certificate/certificate.service.ts +99 -0
  140. package/src/course/course-ai-usage.service.ts +221 -0
  141. package/src/course/course-audio-transcription.service.ts +471 -43
  142. package/src/course/course-export-scorm12.service.ts +149 -5
  143. package/src/course/course-export.service.ts +1 -0
  144. package/src/course/course-lesson.controller.ts +59 -6
  145. package/src/course/course-structure.controller.ts +16 -0
  146. package/src/course/course-structure.service.ts +184 -10
  147. package/src/course/course-transcription-translation.service.ts +293 -0
  148. package/src/course/course-video-agent-pipeline.service.ts +471 -471
  149. package/src/course/course.module.ts +4 -0
  150. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  151. package/src/course/dto/create-course-export.dto.ts +6 -0
  152. package/src/course/ffmpeg.util.ts +65 -65
  153. package/src/course/lms-bulk-upload-automation.service.ts +29 -7
  154. package/src/course/lms-bulk-upload.service.ts +20 -1
  155. package/src/course/subtitle.util.ts +220 -0
  156. package/src/enterprise/training/training-student.service.ts +224 -4
  157. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  158. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  159. package/src/lms.module.ts +4 -0
  160. package/src/platforma/dto/heartbeat.dto.ts +30 -30
  161. package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
  162. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
  163. package/src/platforma/platforma-heartbeat.service.ts +33 -33
  164. package/src/platforma/platforma-performance.service.ts +606 -606
  165. package/src/platforma/platforma-search.service.ts +48 -48
  166. package/src/platforma/platforma-video.service.ts +59 -3
  167. package/src/platforma/platforma.controller.ts +88 -0
  168. package/src/training/dto/create-training.dto.ts +36 -0
  169. package/src/training/training.service.ts +360 -163
@@ -85,12 +85,14 @@ import {
85
85
  Clock,
86
86
  GraduationCap,
87
87
  GripVertical,
88
+ ImageIcon,
88
89
  Layers,
89
90
  Loader2,
90
91
  Pencil,
91
92
  Plus,
92
93
  Target,
93
94
  Trash2,
95
+ Upload,
94
96
  Users,
95
97
  X,
96
98
  } from 'lucide-react';
@@ -122,6 +124,21 @@ interface Formacao {
122
124
  criadoEm: string;
123
125
  primaryColor?: string;
124
126
  secondaryColor?: string;
127
+ bannerFileId?: number | null;
128
+ certificateTemplate?: { id: number; name: string } | null;
129
+ modules?: Array<{
130
+ id: number;
131
+ title: string;
132
+ description: string | null;
133
+ order: number;
134
+ items: LearningPathItem[];
135
+ }>;
136
+ }
137
+
138
+ interface CertificateTemplateOption {
139
+ id: number;
140
+ name: string;
141
+ status: string;
125
142
  }
126
143
 
127
144
  type TrainingColorPayload = {
@@ -159,6 +176,13 @@ interface LearningPathItem {
159
176
  isRequired?: boolean;
160
177
  }
161
178
 
179
+ interface ModuleState {
180
+ uid: string;
181
+ title: string;
182
+ description: string;
183
+ items: LearningPathItem[];
184
+ }
185
+
162
186
  interface TrailRenderableItem {
163
187
  uid: string;
164
188
  type: 'course' | 'exam';
@@ -447,6 +471,8 @@ const formacaoSchema = z.object({
447
471
  .string()
448
472
  .regex(/^#([0-9A-Fa-f]{6})$/, 'Cor secundária inválida')
449
473
  .default('#111827'),
474
+ bannerFileId: z.number().nullable().optional(),
475
+ certificateTemplateId: z.number().nullable().optional(),
450
476
  });
451
477
 
452
478
  type FormacaoForm = z.infer<typeof formacaoSchema>;
@@ -535,6 +561,227 @@ function SortableTrailItem(props: {
535
561
  );
536
562
  }
537
563
 
564
+ function SortableModule(props: {
565
+ module: ModuleState;
566
+ availableCursos: CursoOption[];
567
+ availableExams: ExameOption[];
568
+ sensors: ReturnType<typeof useSensors>;
569
+ onUpdateTitle: (uid: string, title: string) => void;
570
+ onUpdateDescription: (uid: string, description: string) => void;
571
+ onRemoveModule: (uid: string) => void;
572
+ onAddItem: (moduleUid: string, type: 'course' | 'exam', itemId: number) => void;
573
+ onRemoveItem: (moduleUid: string, itemUid: string) => void;
574
+ onDragEndItems: (moduleUid: string, event: DragEndEvent) => void;
575
+ onOpenCreateCourseSheet: (moduleUid: string) => void;
576
+ onOpenCreateExamSheet: (moduleUid: string) => void;
577
+ availableCursosForModule: CursoOption[];
578
+ availableExamsForModule: ExameOption[];
579
+ }) {
580
+ const { module, sensors } = props;
581
+ const { attributes, listeners, setNodeRef, transform, transition } =
582
+ useSortable({ id: module.uid });
583
+
584
+ const style = {
585
+ transform: CSS.Transform.toString(transform),
586
+ transition,
587
+ };
588
+
589
+ const [selectedCourse, setSelectedCourse] = useState('');
590
+ const [selectedExam, setSelectedExam] = useState('');
591
+
592
+ const renderableItems = useMemo<TrailRenderableItem[]>(() => {
593
+ return [...module.items]
594
+ .sort((a, b) => a.order - b.order)
595
+ .map((item, index) => {
596
+ if (item.type === 'course') {
597
+ const course = props.availableCursos.find((c) => c.id === item.itemId);
598
+ if (!course) return null;
599
+ return {
600
+ uid: `course-${course.id}`,
601
+ type: 'course' as const,
602
+ itemId: course.id,
603
+ title: course.nome,
604
+ subtitle: `${course.cargaHoraria}h`,
605
+ order: item.order ?? index,
606
+ };
607
+ }
608
+ const exam = props.availableExams.find((e) => e.id === item.itemId);
609
+ if (!exam) return null;
610
+ return {
611
+ uid: `exam-${exam.id}`,
612
+ type: 'exam' as const,
613
+ itemId: exam.id,
614
+ title: exam.titulo,
615
+ subtitle: `${exam.limiteTempo}min`,
616
+ order: item.order ?? index,
617
+ };
618
+ })
619
+ .filter(Boolean) as TrailRenderableItem[];
620
+ }, [module.items, props.availableCursos, props.availableExams]);
621
+
622
+ function handleCourseSelect(value: string) {
623
+ setSelectedCourse(value);
624
+ const parsed = Number(value);
625
+ if (Number.isFinite(parsed) && parsed > 0) {
626
+ props.onAddItem(module.uid, 'course', parsed);
627
+ setSelectedCourse('');
628
+ }
629
+ }
630
+
631
+ function handleExamSelect(value: string) {
632
+ setSelectedExam(value);
633
+ const parsed = Number(value);
634
+ if (Number.isFinite(parsed) && parsed > 0) {
635
+ props.onAddItem(module.uid, 'exam', parsed);
636
+ setSelectedExam('');
637
+ }
638
+ }
639
+
640
+ return (
641
+ <div ref={setNodeRef} style={style} className="rounded-md border bg-background">
642
+ {/* Module header */}
643
+ <div className="flex items-start gap-2 border-b p-3">
644
+ <Button
645
+ type="button"
646
+ variant="ghost"
647
+ size="icon"
648
+ className="mt-1 size-7 shrink-0 cursor-grab text-muted-foreground"
649
+ aria-label="Arrastar módulo"
650
+ {...attributes}
651
+ {...listeners}
652
+ >
653
+ <GripVertical className="size-4" />
654
+ </Button>
655
+ <div className="flex flex-1 flex-col gap-1">
656
+ <Input
657
+ placeholder="Título do módulo"
658
+ value={module.title}
659
+ onChange={(e) => props.onUpdateTitle(module.uid, e.target.value)}
660
+ className="h-8 text-sm font-medium"
661
+ />
662
+ <Input
663
+ placeholder="Descrição (opcional)"
664
+ value={module.description}
665
+ onChange={(e) => props.onUpdateDescription(module.uid, e.target.value)}
666
+ className="h-7 text-xs text-muted-foreground"
667
+ />
668
+ </div>
669
+ <Button
670
+ type="button"
671
+ variant="ghost"
672
+ size="icon"
673
+ className="mt-1 size-7 shrink-0 text-muted-foreground hover:text-destructive"
674
+ onClick={() => props.onRemoveModule(module.uid)}
675
+ >
676
+ <X className="size-4" />
677
+ </Button>
678
+ </div>
679
+
680
+ {/* Item selectors */}
681
+ <div className="space-y-2 bg-muted/20 p-3">
682
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
683
+ <Select
684
+ value={selectedCourse}
685
+ onValueChange={handleCourseSelect}
686
+ disabled={props.availableCursosForModule.length === 0}
687
+ >
688
+ <SelectTrigger className="w-full">
689
+ <SelectValue placeholder="Adicionar curso..." />
690
+ </SelectTrigger>
691
+ <SelectContent>
692
+ {props.availableCursosForModule.map((course) => (
693
+ <SelectItem key={course.id} value={String(course.id)}>
694
+ <div className="flex items-center gap-2">
695
+ <CourseAvatar
696
+ fileId={course.logoFileId}
697
+ title={course.nome}
698
+ className="size-5 shrink-0 rounded"
699
+ iconSize="size-3"
700
+ />
701
+ <div className="min-w-0">
702
+ <span className="truncate">{course.nome}</span>
703
+ <span className="ml-1.5 text-xs text-muted-foreground">
704
+ {course.name ?? '—'} · #{course.id}
705
+ </span>
706
+ </div>
707
+ </div>
708
+ </SelectItem>
709
+ ))}
710
+ </SelectContent>
711
+ </Select>
712
+ <Button
713
+ type="button"
714
+ variant="outline"
715
+ size="sm"
716
+ className="shrink-0"
717
+ onClick={() => props.onOpenCreateCourseSheet(module.uid)}
718
+ >
719
+ <Plus className="size-4" />
720
+ </Button>
721
+ </div>
722
+
723
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
724
+ <Select
725
+ value={selectedExam}
726
+ onValueChange={handleExamSelect}
727
+ disabled={props.availableExamsForModule.length === 0}
728
+ >
729
+ <SelectTrigger className="w-full">
730
+ <SelectValue placeholder="Adicionar exame..." />
731
+ </SelectTrigger>
732
+ <SelectContent>
733
+ {props.availableExamsForModule.map((exam) => (
734
+ <SelectItem key={exam.id} value={String(exam.id)}>
735
+ {exam.titulo} ({exam.limiteTempo}min)
736
+ </SelectItem>
737
+ ))}
738
+ </SelectContent>
739
+ </Select>
740
+ <Button
741
+ type="button"
742
+ variant="outline"
743
+ size="sm"
744
+ className="shrink-0"
745
+ onClick={() => props.onOpenCreateExamSheet(module.uid)}
746
+ >
747
+ <Plus className="size-4" />
748
+ </Button>
749
+ </div>
750
+ </div>
751
+
752
+ {/* Items list */}
753
+ {renderableItems.length > 0 ? (
754
+ <div className="bg-background">
755
+ <DndContext
756
+ sensors={sensors}
757
+ collisionDetection={closestCenter}
758
+ onDragEnd={(event) => props.onDragEndItems(module.uid, event)}
759
+ >
760
+ <SortableContext
761
+ items={renderableItems.map((item) => item.uid)}
762
+ strategy={verticalListSortingStrategy}
763
+ >
764
+ {renderableItems.map((item) => (
765
+ <SortableTrailItem
766
+ key={item.uid}
767
+ item={item}
768
+ onRemove={(uid) => props.onRemoveItem(module.uid, uid)}
769
+ />
770
+ ))}
771
+ </SortableContext>
772
+ </DndContext>
773
+ </div>
774
+ ) : (
775
+ <div className="p-3">
776
+ <p className="text-xs text-muted-foreground">
777
+ Adicione cursos e/ou exames a este módulo.
778
+ </p>
779
+ </div>
780
+ )}
781
+ </div>
782
+ );
783
+ }
784
+
538
785
  // ── Page ──────────────────────────────────────────────────────────────────────
539
786
 
540
787
  export default function TrainingPage() {
@@ -553,6 +800,8 @@ export default function TrainingPage() {
553
800
  const [learningPathItems, setLearningPathItems] = useState<
554
801
  LearningPathItem[]
555
802
  >([]);
803
+ const [modules, setModules] = useState<ModuleState[]>([]);
804
+ const activeModuleUidRef = useRef<string | null>(null);
556
805
  const [selectedCourseToAdd, setSelectedCourseToAdd] = useState('');
557
806
  const [selectedExamToAdd, setSelectedExamToAdd] = useState('');
558
807
  const [saving, setSaving] = useState(false);
@@ -564,6 +813,9 @@ export default function TrainingPage() {
564
813
  const [cachedListData, setCachedListData] =
565
814
  useState<ApiTrainingListResponse | null>(null);
566
815
  const initialLearningPathRef = useRef<LearningPathItem[]>([]);
816
+ const [bannerPreviewUrl, setBannerPreviewUrl] = useState<string | null>(null);
817
+ const [bannerUploading, setBannerUploading] = useState(false);
818
+ const bannerInputRef = useRef<HTMLInputElement>(null);
567
819
 
568
820
  // Search/filter inputs
569
821
  const [buscaInput, setBuscaInput] = useState('');
@@ -746,6 +998,23 @@ export default function TrainingPage() {
746
998
  },
747
999
  });
748
1000
 
1001
+ const { data: certificateTemplatesData } = useQuery<{
1002
+ data: CertificateTemplateOption[];
1003
+ }>({
1004
+ queryKey: ['lms-certificate-templates-options'],
1005
+ queryFn: async () => {
1006
+ const response = await request<{ data: CertificateTemplateOption[] }>({
1007
+ url: '/lms/certificates/templates',
1008
+ method: 'GET',
1009
+ params: { page: 1, pageSize: 200, status: 'active' },
1010
+ });
1011
+ return response.data;
1012
+ },
1013
+ initialData: { data: [] },
1014
+ });
1015
+
1016
+ const availableCertificateTemplates = certificateTemplatesData?.data ?? [];
1017
+
749
1018
  useEffect(() => {
750
1019
  if (courseSheetOpen) {
751
1020
  void refetchCategories();
@@ -989,6 +1258,9 @@ export default function TrainingPage() {
989
1258
  setEditingFormacao(null);
990
1259
  initialLearningPathRef.current = [];
991
1260
  setLearningPathItems([]);
1261
+ setModules([{ uid: `module-new-${Date.now()}`, title: '', description: '', items: [] }]);
1262
+ activeModuleUidRef.current = null;
1263
+ setBannerPreviewUrl(null);
992
1264
  form.reset({
993
1265
  nome: '',
994
1266
  descricao: '',
@@ -999,6 +1271,7 @@ export default function TrainingPage() {
999
1271
  status: 'rascunho',
1000
1272
  primaryColor: '#1D4ED8',
1001
1273
  secondaryColor: '#111827',
1274
+ certificateTemplateId: null,
1002
1275
  });
1003
1276
  examForm.reset();
1004
1277
  setSelectedCourseToAdd('');
@@ -1045,6 +1318,38 @@ export default function TrainingPage() {
1045
1318
  setLearningPathItems(normalizedItems);
1046
1319
  setSelectedCourseToAdd('');
1047
1320
  setSelectedExamToAdd('');
1321
+
1322
+ if (fullFormacao.modules && fullFormacao.modules.length > 0) {
1323
+ setModules(
1324
+ fullFormacao.modules.map((mod) => ({
1325
+ uid: `module-${mod.id}`,
1326
+ title: mod.title,
1327
+ description: mod.description ?? '',
1328
+ items: mod.items.map((item, i) => ({
1329
+ id: item.id,
1330
+ type: item.type,
1331
+ itemId: item.itemId,
1332
+ order: item.order ?? i,
1333
+ isRequired: item.isRequired !== false,
1334
+ })),
1335
+ }))
1336
+ );
1337
+ } else {
1338
+ setModules([{ uid: `module-new-${Date.now()}`, title: '', description: '', items: normalizedItems }]);
1339
+ }
1340
+
1341
+ setBannerPreviewUrl(null);
1342
+ if (fullFormacao.bannerFileId) {
1343
+ request<{ url?: string }>({
1344
+ url: `/file/open/${fullFormacao.bannerFileId}`,
1345
+ method: 'PUT',
1346
+ })
1347
+ .then((res) => {
1348
+ if (res?.data?.url) setBannerPreviewUrl(res.data.url);
1349
+ })
1350
+ .catch(() => null);
1351
+ }
1352
+
1048
1353
  form.reset({
1049
1354
  nome: fullFormacao.nome,
1050
1355
  descricao: fullFormacao.descricao,
@@ -1057,6 +1362,8 @@ export default function TrainingPage() {
1057
1362
  status: normalizeStatusValue(fullFormacao.status),
1058
1363
  primaryColor: fullFormacao.primaryColor ?? '#1D4ED8',
1059
1364
  secondaryColor: fullFormacao.secondaryColor ?? '#111827',
1365
+ bannerFileId: fullFormacao.bannerFileId ?? null,
1366
+ certificateTemplateId: fullFormacao.certificateTemplate?.id ?? null,
1060
1367
  });
1061
1368
  setSheetOpen(true);
1062
1369
  } finally {
@@ -1138,12 +1445,96 @@ export default function TrainingPage() {
1138
1445
  });
1139
1446
  }
1140
1447
 
1141
- function openCreateCourseSheet() {
1448
+ function addModule() {
1449
+ setModules((prev) => [
1450
+ ...prev,
1451
+ { uid: `module-new-${Date.now()}`, title: '', description: '', items: [] },
1452
+ ]);
1453
+ }
1454
+
1455
+ function removeModule(uid: string) {
1456
+ setModules((prev) => prev.filter((m) => m.uid !== uid));
1457
+ }
1458
+
1459
+ function updateModuleTitle(uid: string, title: string) {
1460
+ setModules((prev) => prev.map((m) => (m.uid === uid ? { ...m, title } : m)));
1461
+ }
1462
+
1463
+ function updateModuleDescription(uid: string, description: string) {
1464
+ setModules((prev) => prev.map((m) => (m.uid === uid ? { ...m, description } : m)));
1465
+ }
1466
+
1467
+ function addItemToModule(moduleUid: string, type: 'course' | 'exam', itemId: number) {
1468
+ setModules((prev) =>
1469
+ prev.map((m) => {
1470
+ if (m.uid !== moduleUid) return m;
1471
+ if (m.items.some((i) => i.type === type && i.itemId === itemId)) return m;
1472
+ return {
1473
+ ...m,
1474
+ items: normalizeTrailOrder([
1475
+ ...m.items,
1476
+ { type, itemId, order: m.items.length, isRequired: true },
1477
+ ]),
1478
+ };
1479
+ })
1480
+ );
1481
+ }
1482
+
1483
+ function removeItemFromModule(moduleUid: string, itemUid: string) {
1484
+ const [type, idText] = itemUid.split('-');
1485
+ const itemId = Number(idText);
1486
+ if (!itemId || (type !== 'course' && type !== 'exam')) return;
1487
+ setModules((prev) =>
1488
+ prev.map((m) => {
1489
+ if (m.uid !== moduleUid) return m;
1490
+ return {
1491
+ ...m,
1492
+ items: normalizeTrailOrder(
1493
+ m.items.filter((i) => !(i.type === type && i.itemId === itemId))
1494
+ ),
1495
+ };
1496
+ })
1497
+ );
1498
+ }
1499
+
1500
+ function handleModuleDragEnd(event: DragEndEvent) {
1501
+ const { active, over } = event;
1502
+ if (!over || active.id === over.id) return;
1503
+ setModules((prev) => {
1504
+ const oldIndex = prev.findIndex((m) => m.uid === String(active.id));
1505
+ const newIndex = prev.findIndex((m) => m.uid === String(over.id));
1506
+ if (oldIndex < 0 || newIndex < 0) return prev;
1507
+ return arrayMove(prev, oldIndex, newIndex);
1508
+ });
1509
+ }
1510
+
1511
+ function handleModuleItemDragEnd(moduleUid: string, event: DragEndEvent) {
1512
+ const { active, over } = event;
1513
+ if (!over || active.id === over.id) return;
1514
+ setModules((prev) =>
1515
+ prev.map((m) => {
1516
+ if (m.uid !== moduleUid) return m;
1517
+ const sorted = [...m.items].sort((a, b) => a.order - b.order);
1518
+ const oldIndex = sorted.findIndex(
1519
+ (i) => `${i.type}-${i.itemId}` === String(active.id)
1520
+ );
1521
+ const newIndex = sorted.findIndex(
1522
+ (i) => `${i.type}-${i.itemId}` === String(over.id)
1523
+ );
1524
+ if (oldIndex < 0 || newIndex < 0) return m;
1525
+ return { ...m, items: normalizeTrailOrder(arrayMove(sorted, oldIndex, newIndex)) };
1526
+ })
1527
+ );
1528
+ }
1529
+
1530
+ function openCreateCourseSheet(moduleUid?: string) {
1531
+ if (moduleUid) activeModuleUidRef.current = moduleUid;
1142
1532
  courseForm.reset(DEFAULT_COURSE_FORM_VALUES);
1143
1533
  setCourseSheetOpen(true);
1144
1534
  }
1145
1535
 
1146
- function openCreateExamSheet() {
1536
+ function openCreateExamSheet(moduleUid?: string) {
1537
+ if (moduleUid) activeModuleUidRef.current = moduleUid;
1147
1538
  examForm.reset({
1148
1539
  titulo: '',
1149
1540
  notaMinima: 7,
@@ -1199,28 +1590,71 @@ export default function TrainingPage() {
1199
1590
  });
1200
1591
  }
1201
1592
 
1593
+ async function handleBannerUpload(e: React.ChangeEvent<HTMLInputElement>) {
1594
+ const file = e.target.files?.[0];
1595
+ if (!file) return;
1596
+
1597
+ setBannerUploading(true);
1598
+ try {
1599
+ const formData = new FormData();
1600
+ formData.append('file', file);
1601
+ const uploadRes = await request<{ id?: number }>({
1602
+ url: '/file',
1603
+ method: 'POST',
1604
+ data: formData,
1605
+ headers: { 'Content-Type': 'multipart/form-data' },
1606
+ });
1607
+ const fileId = uploadRes?.data?.id;
1608
+ if (!fileId) return;
1609
+
1610
+ const openRes = await request<{ url?: string }>({
1611
+ url: `/file/open/${fileId}`,
1612
+ method: 'PUT',
1613
+ });
1614
+ if (openRes?.data?.url) setBannerPreviewUrl(openRes.data.url);
1615
+ form.setValue('bannerFileId', fileId, { shouldDirty: true });
1616
+ } finally {
1617
+ setBannerUploading(false);
1618
+ if (bannerInputRef.current) bannerInputRef.current.value = '';
1619
+ }
1620
+ }
1621
+
1622
+ function handleRemoveBanner() {
1623
+ setBannerPreviewUrl(null);
1624
+ form.setValue('bannerFileId', null, { shouldDirty: true });
1625
+ if (bannerInputRef.current) bannerInputRef.current.value = '';
1626
+ }
1627
+
1202
1628
  async function onSubmit(data: FormacaoForm) {
1203
1629
  const toApiProgressMode = (value: FormacaoForm['progressionMode']) =>
1204
1630
  value === 'livre' ? 'free' : 'sequential';
1205
1631
 
1206
- try {
1207
- const orderedItems = normalizeTrailOrder(
1208
- [...learningPathItems].sort((a, b) => a.order - b.order)
1209
- );
1632
+ if (modules.some((m) => !m.title.trim())) {
1633
+ toast.error('Todos os módulos precisam de um título.');
1634
+ return;
1635
+ }
1636
+ if (modules.length === 0) {
1637
+ toast.error(t('toasts.selectAtLeastOneItem'));
1638
+ return;
1639
+ }
1210
1640
 
1211
- if (orderedItems.length === 0) {
1212
- toast.error(t('toasts.selectAtLeastOneItem'));
1213
- return;
1214
- }
1641
+ const modulesPayload = modules.map((mod, i) => ({
1642
+ title: mod.title.trim(),
1643
+ description: mod.description.trim() || undefined,
1644
+ order: i,
1645
+ items: mod.items.map((item, j) => ({
1646
+ type: item.type,
1647
+ itemId: item.itemId,
1648
+ order: j,
1649
+ isRequired: item.isRequired !== false,
1650
+ })),
1651
+ }));
1215
1652
 
1653
+ try {
1216
1654
  setSaving(true);
1217
1655
 
1218
1656
  if (editingFormacao) {
1219
1657
  const dirty = form.formState.dirtyFields;
1220
- const itemsChanged = !pathsAreEqual(
1221
- initialLearningPathRef.current,
1222
- orderedItems
1223
- );
1224
1658
 
1225
1659
  const payload: {
1226
1660
  title?: string;
@@ -1231,12 +1665,9 @@ export default function TrainingPage() {
1231
1665
  progressMode?: 'sequential' | 'free';
1232
1666
  primaryColor?: string;
1233
1667
  secondaryColor?: string;
1234
- items?: Array<{
1235
- type: 'course' | 'exam';
1236
- itemId: number;
1237
- order: number;
1238
- isRequired: boolean;
1239
- }>;
1668
+ bannerFileId?: number | null;
1669
+ certificateTemplateId?: number | null;
1670
+ modules?: typeof modulesPayload;
1240
1671
  } = {};
1241
1672
 
1242
1673
  if (dirty.nome) payload.title = data.nome;
@@ -1251,19 +1682,9 @@ export default function TrainingPage() {
1251
1682
  payload.progressMode = toApiProgressMode(data.progressionMode);
1252
1683
  if (dirty.primaryColor) payload.primaryColor = data.primaryColor;
1253
1684
  if (dirty.secondaryColor) payload.secondaryColor = data.secondaryColor;
1254
- if (itemsChanged) {
1255
- payload.items = orderedItems.map((item, index) => ({
1256
- type: item.type,
1257
- itemId: item.itemId,
1258
- order: index,
1259
- isRequired: item.isRequired !== false,
1260
- }));
1261
- }
1262
-
1263
- if (Object.keys(payload).length === 0) {
1264
- setSheetOpen(false);
1265
- return;
1266
- }
1685
+ if (dirty.bannerFileId) payload.bannerFileId = data.bannerFileId ?? null;
1686
+ if (dirty.certificateTemplateId) payload.certificateTemplateId = data.certificateTemplateId ?? null;
1687
+ payload.modules = modulesPayload;
1267
1688
 
1268
1689
  await request({
1269
1690
  url: `/lms/paths/${editingFormacao.id}`,
@@ -1281,12 +1702,9 @@ export default function TrainingPage() {
1281
1702
  progressMode: toApiProgressMode(data.progressionMode),
1282
1703
  primaryColor: data.primaryColor,
1283
1704
  secondaryColor: data.secondaryColor,
1284
- items: orderedItems.map((item, index) => ({
1285
- type: item.type,
1286
- itemId: item.itemId,
1287
- order: index,
1288
- isRequired: item.isRequired !== false,
1289
- })),
1705
+ bannerFileId: data.bannerFileId ?? null,
1706
+ certificateTemplateId: data.certificateTemplateId ?? null,
1707
+ modules: modulesPayload,
1290
1708
  };
1291
1709
 
1292
1710
  await request({
@@ -1300,6 +1718,7 @@ export default function TrainingPage() {
1300
1718
  await Promise.all([refetchTraining(), refetchStats()]);
1301
1719
  setSheetOpen(false);
1302
1720
  setLearningPathItems([]);
1721
+ setModules([]);
1303
1722
  initialLearningPathRef.current = [];
1304
1723
  } finally {
1305
1724
  setSaving(false);
@@ -1339,7 +1758,12 @@ export default function TrainingPage() {
1339
1758
 
1340
1759
  await refetchCourses();
1341
1760
  if (Number.isFinite(createdCourseId) && createdCourseId > 0) {
1342
- addTrailItem('course', createdCourseId);
1761
+ const targetUid = activeModuleUidRef.current ?? modules[modules.length - 1]?.uid;
1762
+ if (targetUid) {
1763
+ addItemToModule(targetUid, 'course', createdCourseId);
1764
+ } else {
1765
+ addTrailItem('course', createdCourseId);
1766
+ }
1343
1767
  setSelectedCourseToAdd('');
1344
1768
  }
1345
1769
 
@@ -1370,7 +1794,12 @@ export default function TrainingPage() {
1370
1794
 
1371
1795
  await refetchExams();
1372
1796
  if (Number.isFinite(createdExamId) && createdExamId > 0) {
1373
- addTrailItem('exam', createdExamId);
1797
+ const targetUid = activeModuleUidRef.current ?? modules[modules.length - 1]?.uid;
1798
+ if (targetUid) {
1799
+ addItemToModule(targetUid, 'exam', createdExamId);
1800
+ } else {
1801
+ addTrailItem('exam', createdExamId);
1802
+ }
1374
1803
  setSelectedExamToAdd('');
1375
1804
  }
1376
1805
 
@@ -1930,6 +2359,60 @@ export default function TrainingPage() {
1930
2359
  {...form.register('descricao')}
1931
2360
  />
1932
2361
  </Field>
2362
+ {/* Banner da trilha */}
2363
+ <Field>
2364
+ <FieldLabel>Banner da trilha</FieldLabel>
2365
+ <div className="flex items-center gap-3">
2366
+ {bannerPreviewUrl ? (
2367
+ <img
2368
+ src={bannerPreviewUrl}
2369
+ alt="Banner da trilha"
2370
+ className="h-12 w-32 shrink-0 rounded-lg border object-cover"
2371
+ />
2372
+ ) : (
2373
+ <div className="flex h-12 w-32 shrink-0 items-center justify-center rounded-lg border border-dashed bg-muted/40">
2374
+ <ImageIcon className="size-5 text-muted-foreground/50" />
2375
+ </div>
2376
+ )}
2377
+ <div className="flex items-center gap-2">
2378
+ <input
2379
+ ref={bannerInputRef}
2380
+ type="file"
2381
+ accept="image/*"
2382
+ className="hidden"
2383
+ onChange={handleBannerUpload}
2384
+ />
2385
+ <Button
2386
+ type="button"
2387
+ variant="outline"
2388
+ size="sm"
2389
+ disabled={bannerUploading}
2390
+ onClick={() => bannerInputRef.current?.click()}
2391
+ className="gap-2"
2392
+ >
2393
+ {bannerUploading ? (
2394
+ <Loader2 className="size-3.5 animate-spin" />
2395
+ ) : (
2396
+ <Upload className="size-3.5" />
2397
+ )}
2398
+ {bannerPreviewUrl ? 'Substituir' : 'Fazer upload'}
2399
+ </Button>
2400
+ {bannerPreviewUrl && (
2401
+ <Button
2402
+ type="button"
2403
+ variant="ghost"
2404
+ size="sm"
2405
+ onClick={handleRemoveBanner}
2406
+ className="gap-2 text-destructive hover:text-destructive"
2407
+ >
2408
+ <X className="size-3.5" />
2409
+ Remover
2410
+ </Button>
2411
+ )}
2412
+ </div>
2413
+ </div>
2414
+ </Field>
2415
+
1933
2416
  <Field>
1934
2417
  <FieldLabel>
1935
2418
  {t('form.fields.progressionMode.label')}{' '}
@@ -2042,6 +2525,39 @@ export default function TrainingPage() {
2042
2525
  />
2043
2526
  </Field>
2044
2527
 
2528
+ <Field>
2529
+ <FieldLabel>Template de certificado</FieldLabel>
2530
+ <Controller
2531
+ name="certificateTemplateId"
2532
+ control={form.control}
2533
+ render={({ field }) => (
2534
+ <Select
2535
+ value={field.value != null ? String(field.value) : '__none__'}
2536
+ onValueChange={(v) =>
2537
+ field.onChange(v === '__none__' ? null : Number(v))
2538
+ }
2539
+ >
2540
+ <SelectTrigger>
2541
+ <SelectValue placeholder="Selecione um template..." />
2542
+ </SelectTrigger>
2543
+ <SelectContent>
2544
+ <SelectItem value="__none__">
2545
+ Sem template vinculado
2546
+ </SelectItem>
2547
+ {availableCertificateTemplates.map((tpl) => (
2548
+ <SelectItem key={tpl.id} value={String(tpl.id)}>
2549
+ {tpl.name}
2550
+ </SelectItem>
2551
+ ))}
2552
+ </SelectContent>
2553
+ </Select>
2554
+ )}
2555
+ />
2556
+ <p className="text-xs text-muted-foreground">
2557
+ O template escolhido será exibido como preview do certificado na área do aluno.
2558
+ </p>
2559
+ </Field>
2560
+
2045
2561
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
2046
2562
  <Field>
2047
2563
  <FieldLabel htmlFor="primaryColor">
@@ -2102,120 +2618,63 @@ export default function TrainingPage() {
2102
2618
  </Field>
2103
2619
  </div>
2104
2620
 
2105
- {/* Trilha */}
2621
+ {/* Módulos da trilha */}
2106
2622
  <Field>
2107
- <FieldLabel>{t('form.fields.trilha.label')}</FieldLabel>
2623
+ <FieldLabel>Módulos da trilha</FieldLabel>
2108
2624
  <div className="space-y-2">
2109
- <div className="rounded-md border bg-muted/20 p-3">
2110
- <div className="space-y-2">
2111
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2112
- <Select
2113
- value={selectedCourseToAdd}
2114
- onValueChange={handleCourseSelection}
2115
- disabled={selectableCursos.length === 0}
2116
- >
2117
- <SelectTrigger className="w-full">
2118
- <SelectValue
2119
- placeholder={t('form.fields.cursos.placeholder')}
2120
- />
2121
- </SelectTrigger>
2122
- <SelectContent>
2123
- {selectableCursos.map((course) => (
2124
- <SelectItem
2125
- key={course.id}
2126
- value={String(course.id)}
2127
- >
2128
- <div className="flex items-center gap-2">
2129
- <CourseAvatar
2130
- fileId={course.logoFileId}
2131
- title={course.nome}
2132
- className="size-5 shrink-0 rounded"
2133
- iconSize="size-3"
2134
- />
2135
- <div className="min-w-0">
2136
- <span className="truncate">
2137
- {course.nome}
2138
- </span>
2139
- <span className="ml-1.5 text-xs text-muted-foreground">
2140
- {course.name ?? '—'} · #{course.id}
2141
- </span>
2142
- </div>
2143
- </div>
2144
- </SelectItem>
2145
- ))}
2146
- </SelectContent>
2147
- </Select>
2148
- <Button
2149
- type="button"
2150
- variant="outline"
2151
- className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2152
- onClick={openCreateCourseSheet}
2153
- >
2154
- <Plus className="size-4" />
2155
- </Button>
2156
- </div>
2157
-
2158
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2159
- <Select
2160
- value={selectedExamToAdd}
2161
- onValueChange={handleExamSelection}
2162
- disabled={selectableExams.length === 0}
2163
- >
2164
- <SelectTrigger className="w-full">
2165
- <SelectValue
2166
- placeholder={t('form.fields.exames.placeholder')}
2167
- />
2168
- </SelectTrigger>
2169
- <SelectContent>
2170
- {selectableExams.map((exam) => (
2171
- <SelectItem key={exam.id} value={String(exam.id)}>
2172
- {exam.titulo} ({exam.limiteTempo}min)
2173
- </SelectItem>
2174
- ))}
2175
- </SelectContent>
2176
- </Select>
2177
- <Button
2178
- type="button"
2179
- variant="outline"
2180
- className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2181
- onClick={openCreateExamSheet}
2182
- >
2183
- <Plus className="size-4" />
2184
- </Button>
2185
- </div>
2186
- </div>
2187
- </div>
2188
-
2189
- {trailItems.length > 0 ? (
2190
- <>
2191
- <div className="rounded-md border bg-background">
2192
- <DndContext
2193
- sensors={sensors}
2194
- collisionDetection={closestCenter}
2195
- onDragEnd={handleTrailDragEnd}
2196
- >
2197
- <SortableContext
2198
- items={trailItems.map((item) => item.uid)}
2199
- strategy={verticalListSortingStrategy}
2200
- >
2201
- {trailItems.map((item) => (
2202
- <SortableTrailItem
2203
- key={item.uid}
2204
- item={item}
2205
- onRemove={removeTrailItem}
2206
- />
2207
- ))}
2208
- </SortableContext>
2209
- </DndContext>
2210
- </div>
2211
- </>
2212
- ) : (
2213
- <div className="rounded-md border p-3">
2214
- <p className="text-sm text-muted-foreground">
2215
- {t('form.fields.trilha.empty')}
2216
- </p>
2217
- </div>
2218
- )}
2625
+ <DndContext
2626
+ sensors={sensors}
2627
+ collisionDetection={closestCenter}
2628
+ onDragEnd={handleModuleDragEnd}
2629
+ >
2630
+ <SortableContext
2631
+ items={modules.map((m) => m.uid)}
2632
+ strategy={verticalListSortingStrategy}
2633
+ >
2634
+ {modules.map((mod) => {
2635
+ const usedCourseIds = mod.items
2636
+ .filter((i) => i.type === 'course')
2637
+ .map((i) => i.itemId);
2638
+ const usedExamIds = mod.items
2639
+ .filter((i) => i.type === 'exam')
2640
+ .map((i) => i.itemId);
2641
+ return (
2642
+ <SortableModule
2643
+ key={mod.uid}
2644
+ module={mod}
2645
+ availableCursos={selectableCursos}
2646
+ availableExams={selectableExams}
2647
+ availableCursosForModule={selectableCursos.filter(
2648
+ (c) => !usedCourseIds.includes(c.id)
2649
+ )}
2650
+ availableExamsForModule={selectableExams.filter(
2651
+ (e) => !usedExamIds.includes(e.id)
2652
+ )}
2653
+ sensors={sensors}
2654
+ onUpdateTitle={updateModuleTitle}
2655
+ onUpdateDescription={updateModuleDescription}
2656
+ onRemoveModule={removeModule}
2657
+ onAddItem={addItemToModule}
2658
+ onRemoveItem={removeItemFromModule}
2659
+ onDragEndItems={handleModuleItemDragEnd}
2660
+ onOpenCreateCourseSheet={openCreateCourseSheet}
2661
+ onOpenCreateExamSheet={openCreateExamSheet}
2662
+ />
2663
+ );
2664
+ })}
2665
+ </SortableContext>
2666
+ </DndContext>
2667
+
2668
+ <Button
2669
+ type="button"
2670
+ variant="outline"
2671
+ size="sm"
2672
+ className="mt-1 w-full gap-2"
2673
+ onClick={addModule}
2674
+ >
2675
+ <Plus className="size-4" />
2676
+ Adicionar módulo
2677
+ </Button>
2219
2678
  </div>
2220
2679
 
2221
2680
  {(isFetchingCourses || isFetchingExams) && (
@@ -2223,17 +2682,6 @@ export default function TrainingPage() {
2223
2682
  {t('form.fields.trilha.loading')}
2224
2683
  </p>
2225
2684
  )}
2226
-
2227
- {learningPathItems.length > 0 && (
2228
- <p className="text-xs text-muted-foreground mt-1">
2229
- {learningPathItems.length} {t('coursesSummary.items')}{' '}
2230
- {t('coursesSummary.dot')}{' '}
2231
- {availableCursos
2232
- .filter((c) => selectedCursos.includes(c.id))
2233
- .reduce((a, c) => a + c.cargaHoraria, 0)}
2234
- {t('coursesSummary.hours')}
2235
- </p>
2236
- )}
2237
2685
  </Field>
2238
2686
 
2239
2687
  <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">