@hed-hog/lms 0.0.365 → 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 (243) 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/class-group/class-group.controller.d.ts +1 -0
  10. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  11. package/dist/class-group/class-group.service.d.ts +1 -0
  12. package/dist/class-group/class-group.service.d.ts.map +1 -1
  13. package/dist/course/course-ai-usage.service.d.ts +58 -0
  14. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  15. package/dist/course/course-ai-usage.service.js +176 -0
  16. package/dist/course/course-ai-usage.service.js.map +1 -0
  17. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  18. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  19. package/dist/course/course-audio-transcription.service.js +381 -29
  20. package/dist/course/course-audio-transcription.service.js.map +1 -1
  21. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  22. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  23. package/dist/course/course-export-scorm12.service.js +141 -6
  24. package/dist/course/course-export-scorm12.service.js.map +1 -1
  25. package/dist/course/course-export.service.d.ts.map +1 -1
  26. package/dist/course/course-export.service.js +2 -1
  27. package/dist/course/course-export.service.js.map +1 -1
  28. package/dist/course/course-lesson.controller.d.ts +25 -3
  29. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  30. package/dist/course/course-lesson.controller.js +71 -8
  31. package/dist/course/course-lesson.controller.js.map +1 -1
  32. package/dist/course/course-structure.controller.d.ts +30 -7
  33. package/dist/course/course-structure.controller.d.ts.map +1 -1
  34. package/dist/course/course-structure.controller.js +37 -4
  35. package/dist/course/course-structure.controller.js.map +1 -1
  36. package/dist/course/course-structure.service.d.ts +37 -5
  37. package/dist/course/course-structure.service.d.ts.map +1 -1
  38. package/dist/course/course-structure.service.js +165 -20
  39. package/dist/course/course-structure.service.js.map +1 -1
  40. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  41. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  42. package/dist/course/course-transcription-translation.service.js +227 -0
  43. package/dist/course/course-transcription-translation.service.js.map +1 -0
  44. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  45. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  46. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  47. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  48. package/dist/course/course-video-hls.service.d.ts +14 -0
  49. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  50. package/dist/course/course-video-hls.service.js +25 -8
  51. package/dist/course/course-video-hls.service.js.map +1 -1
  52. package/dist/course/course.controller.d.ts +2 -0
  53. package/dist/course/course.controller.d.ts.map +1 -1
  54. package/dist/course/course.module.d.ts.map +1 -1
  55. package/dist/course/course.module.js +9 -0
  56. package/dist/course/course.module.js.map +1 -1
  57. package/dist/course/course.service.d.ts +2 -0
  58. package/dist/course/course.service.d.ts.map +1 -1
  59. package/dist/course/course.service.js +36 -2
  60. package/dist/course/course.service.js.map +1 -1
  61. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  62. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  63. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  64. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  65. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  66. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  67. package/dist/course/dto/create-course-export.dto.js +6 -0
  68. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  69. package/dist/course/ffmpeg.util.d.ts +10 -0
  70. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  71. package/dist/course/ffmpeg.util.js +79 -0
  72. package/dist/course/ffmpeg.util.js.map +1 -0
  73. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  74. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  75. package/dist/course/lms-bulk-upload-automation.service.js +33 -16
  76. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  77. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  78. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  79. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  80. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  81. package/dist/course/lms-bulk-upload.service.js +48 -29
  82. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  83. package/dist/course/subtitle.util.d.ts +46 -0
  84. package/dist/course/subtitle.util.d.ts.map +1 -0
  85. package/dist/course/subtitle.util.js +206 -0
  86. package/dist/course/subtitle.util.js.map +1 -0
  87. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  88. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  89. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  90. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  91. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  92. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  93. package/dist/enterprise/training/training-student.service.js +197 -10
  94. package/dist/enterprise/training/training-student.service.js.map +1 -1
  95. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  96. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  97. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  98. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  99. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  100. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  101. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  102. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  103. package/dist/lms.module.d.ts.map +1 -1
  104. package/dist/lms.module.js +14 -0
  105. package/dist/lms.module.js.map +1 -1
  106. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  107. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  108. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  109. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  110. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  111. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  112. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  113. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  114. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  115. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  116. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  117. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  118. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  119. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  120. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  121. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  122. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  123. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  124. package/dist/platforma/platforma-performance.service.js +500 -0
  125. package/dist/platforma/platforma-performance.service.js.map +1 -0
  126. package/dist/platforma/platforma-search.service.d.ts +21 -0
  127. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  128. package/dist/platforma/platforma-search.service.js +64 -0
  129. package/dist/platforma/platforma-search.service.js.map +1 -0
  130. package/dist/platforma/platforma-video.service.d.ts +8 -0
  131. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  132. package/dist/platforma/platforma-video.service.js +45 -2
  133. package/dist/platforma/platforma-video.service.js.map +1 -1
  134. package/dist/platforma/platforma.controller.d.ts +213 -1
  135. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  136. package/dist/platforma/platforma.controller.js +159 -2
  137. package/dist/platforma/platforma.controller.js.map +1 -1
  138. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  139. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  140. package/dist/realtime/lms-realtime.controller.js +31 -0
  141. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  142. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  143. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  144. package/dist/realtime/lms-realtime.service.js.map +1 -1
  145. package/dist/training/dto/create-training.dto.d.ts +9 -0
  146. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  147. package/dist/training/dto/create-training.dto.js +45 -1
  148. package/dist/training/dto/create-training.dto.js.map +1 -1
  149. package/dist/training/training.controller.d.ts +144 -0
  150. package/dist/training/training.controller.d.ts.map +1 -1
  151. package/dist/training/training.service.d.ts +149 -0
  152. package/dist/training/training.service.d.ts.map +1 -1
  153. package/dist/training/training.service.js +332 -167
  154. package/dist/training/training.service.js.map +1 -1
  155. package/hedhog/data/image_type.yaml +10 -0
  156. package/hedhog/data/route.yaml +251 -0
  157. package/hedhog/data/setting_group.yaml +97 -0
  158. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  159. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  160. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  161. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  162. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  163. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  164. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  165. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  166. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  167. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  168. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  169. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  170. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  171. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  172. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  173. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  174. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  175. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  176. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  177. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  178. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  179. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  180. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  181. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  182. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  183. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  184. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  185. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  186. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  187. package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
  188. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  189. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  190. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  191. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  192. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  193. package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
  194. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  195. package/hedhog/frontend/messages/en.json +41 -12
  196. package/hedhog/frontend/messages/pt.json +44 -13
  197. package/hedhog/query/triggers.sql +33 -0
  198. package/hedhog/table/course_ai_usage.yaml +46 -0
  199. package/hedhog/table/course_enrollment.yaml +3 -0
  200. package/hedhog/table/course_lesson.yaml +3 -0
  201. package/hedhog/table/course_lesson_answer.yaml +37 -0
  202. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  203. package/hedhog/table/learning_path.yaml +6 -0
  204. package/hedhog/table/learning_path_module.yaml +22 -0
  205. package/hedhog/table/learning_path_step.yaml +9 -6
  206. package/hedhog/table/lesson_view_event.yaml +66 -0
  207. package/package.json +8 -7
  208. package/src/certificate/certificate.controller.ts +2 -0
  209. package/src/certificate/certificate.service.ts +99 -0
  210. package/src/course/course-ai-usage.service.ts +221 -0
  211. package/src/course/course-audio-transcription.service.ts +471 -43
  212. package/src/course/course-export-scorm12.service.ts +149 -5
  213. package/src/course/course-export.service.ts +1 -0
  214. package/src/course/course-lesson.controller.ts +59 -6
  215. package/src/course/course-structure.controller.ts +19 -1
  216. package/src/course/course-structure.service.ts +184 -10
  217. package/src/course/course-transcription-translation.service.ts +293 -0
  218. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  219. package/src/course/course-video-hls.service.ts +30 -10
  220. package/src/course/course.module.ts +9 -0
  221. package/src/course/course.service.ts +46 -1
  222. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  223. package/src/course/dto/create-course-export.dto.ts +6 -0
  224. package/src/course/ffmpeg.util.ts +65 -0
  225. package/src/course/lms-bulk-upload-automation.service.ts +33 -8
  226. package/src/course/lms-bulk-upload.service.ts +20 -1
  227. package/src/course/subtitle.util.ts +220 -0
  228. package/src/enterprise/training/training-student.service.ts +224 -4
  229. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  230. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  231. package/src/lms.module.ts +14 -0
  232. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  233. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  234. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  235. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  236. package/src/platforma/platforma-performance.service.ts +606 -0
  237. package/src/platforma/platforma-search.service.ts +48 -0
  238. package/src/platforma/platforma-video.service.ts +59 -3
  239. package/src/platforma/platforma.controller.ts +130 -0
  240. package/src/realtime/lms-realtime.controller.ts +27 -1
  241. package/src/realtime/lms-realtime.service.ts +2 -1
  242. package/src/training/dto/create-training.dto.ts +36 -0
  243. 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>;
@@ -462,7 +488,7 @@ const STATUS_MAP: Record<
462
488
  encerrada: { label: 'Encerrada', variant: 'outline' },
463
489
  };
464
490
 
465
- const PAGE_SIZES = [6, 12, 24];
491
+ const DEFAULT_PAGE_SIZES = [6, 12, 24, 48, 96] as const;
466
492
  const API_TRAINING_CACHE_KEY = 'lms:training:api-cache';
467
493
 
468
494
  // ── Animations ────────────────────────────────────────────────────────────────
@@ -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('');
@@ -578,10 +830,44 @@ export default function TrainingPage() {
578
830
 
579
831
  // Pagination
580
832
  const [currentPage, setCurrentPage] = useState(1);
833
+
834
+ const { data: generalSettings } = useQuery<{
835
+ data: Array<{ slug: string; value: string }>;
836
+ }>({
837
+ queryKey: ['setting-group-general'],
838
+ queryFn: async () => {
839
+ const response = await request<{
840
+ data: Array<{ slug: string; value: string }>;
841
+ }>({
842
+ url: '/setting/group/general',
843
+ method: 'GET',
844
+ });
845
+ return response.data;
846
+ },
847
+ staleTime: 5 * 60 * 1000,
848
+ });
849
+
850
+ const pageSizeOptions = useMemo(() => {
851
+ const setting = generalSettings?.data?.find(
852
+ (s) => s.slug === 'pagination-page-sizes'
853
+ );
854
+ if (!setting?.value) return DEFAULT_PAGE_SIZES;
855
+ try {
856
+ const parsed = JSON.parse(setting.value) as string[];
857
+ const sizes = parsed
858
+ .map(Number)
859
+ .filter((n) => !isNaN(n) && n > 0)
860
+ .sort((a, b) => a - b);
861
+ return sizes.length > 0 ? sizes : DEFAULT_PAGE_SIZES;
862
+ } catch {
863
+ return DEFAULT_PAGE_SIZES;
864
+ }
865
+ }, [generalSettings]);
866
+
581
867
  const [pageSize, setPageSize] = usePersistedPageSize({
582
868
  storageKey: 'pagination:global:pageSize',
583
869
  defaultValue: 12,
584
- allowedValues: PAGE_SIZES,
870
+ allowedValues: pageSizeOptions,
585
871
  });
586
872
 
587
873
  const sensors = useSensors(
@@ -712,6 +998,23 @@ export default function TrainingPage() {
712
998
  },
713
999
  });
714
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
+
715
1018
  useEffect(() => {
716
1019
  if (courseSheetOpen) {
717
1020
  void refetchCategories();
@@ -955,6 +1258,9 @@ export default function TrainingPage() {
955
1258
  setEditingFormacao(null);
956
1259
  initialLearningPathRef.current = [];
957
1260
  setLearningPathItems([]);
1261
+ setModules([{ uid: `module-new-${Date.now()}`, title: '', description: '', items: [] }]);
1262
+ activeModuleUidRef.current = null;
1263
+ setBannerPreviewUrl(null);
958
1264
  form.reset({
959
1265
  nome: '',
960
1266
  descricao: '',
@@ -965,6 +1271,7 @@ export default function TrainingPage() {
965
1271
  status: 'rascunho',
966
1272
  primaryColor: '#1D4ED8',
967
1273
  secondaryColor: '#111827',
1274
+ certificateTemplateId: null,
968
1275
  });
969
1276
  examForm.reset();
970
1277
  setSelectedCourseToAdd('');
@@ -1011,6 +1318,38 @@ export default function TrainingPage() {
1011
1318
  setLearningPathItems(normalizedItems);
1012
1319
  setSelectedCourseToAdd('');
1013
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
+
1014
1353
  form.reset({
1015
1354
  nome: fullFormacao.nome,
1016
1355
  descricao: fullFormacao.descricao,
@@ -1023,6 +1362,8 @@ export default function TrainingPage() {
1023
1362
  status: normalizeStatusValue(fullFormacao.status),
1024
1363
  primaryColor: fullFormacao.primaryColor ?? '#1D4ED8',
1025
1364
  secondaryColor: fullFormacao.secondaryColor ?? '#111827',
1365
+ bannerFileId: fullFormacao.bannerFileId ?? null,
1366
+ certificateTemplateId: fullFormacao.certificateTemplate?.id ?? null,
1026
1367
  });
1027
1368
  setSheetOpen(true);
1028
1369
  } finally {
@@ -1104,12 +1445,96 @@ export default function TrainingPage() {
1104
1445
  });
1105
1446
  }
1106
1447
 
1107
- 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;
1108
1532
  courseForm.reset(DEFAULT_COURSE_FORM_VALUES);
1109
1533
  setCourseSheetOpen(true);
1110
1534
  }
1111
1535
 
1112
- function openCreateExamSheet() {
1536
+ function openCreateExamSheet(moduleUid?: string) {
1537
+ if (moduleUid) activeModuleUidRef.current = moduleUid;
1113
1538
  examForm.reset({
1114
1539
  titulo: '',
1115
1540
  notaMinima: 7,
@@ -1165,28 +1590,71 @@ export default function TrainingPage() {
1165
1590
  });
1166
1591
  }
1167
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
+
1168
1628
  async function onSubmit(data: FormacaoForm) {
1169
1629
  const toApiProgressMode = (value: FormacaoForm['progressionMode']) =>
1170
1630
  value === 'livre' ? 'free' : 'sequential';
1171
1631
 
1172
- try {
1173
- const orderedItems = normalizeTrailOrder(
1174
- [...learningPathItems].sort((a, b) => a.order - b.order)
1175
- );
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
+ }
1176
1640
 
1177
- if (orderedItems.length === 0) {
1178
- toast.error(t('toasts.selectAtLeastOneItem'));
1179
- return;
1180
- }
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
+ }));
1181
1652
 
1653
+ try {
1182
1654
  setSaving(true);
1183
1655
 
1184
1656
  if (editingFormacao) {
1185
1657
  const dirty = form.formState.dirtyFields;
1186
- const itemsChanged = !pathsAreEqual(
1187
- initialLearningPathRef.current,
1188
- orderedItems
1189
- );
1190
1658
 
1191
1659
  const payload: {
1192
1660
  title?: string;
@@ -1197,12 +1665,9 @@ export default function TrainingPage() {
1197
1665
  progressMode?: 'sequential' | 'free';
1198
1666
  primaryColor?: string;
1199
1667
  secondaryColor?: string;
1200
- items?: Array<{
1201
- type: 'course' | 'exam';
1202
- itemId: number;
1203
- order: number;
1204
- isRequired: boolean;
1205
- }>;
1668
+ bannerFileId?: number | null;
1669
+ certificateTemplateId?: number | null;
1670
+ modules?: typeof modulesPayload;
1206
1671
  } = {};
1207
1672
 
1208
1673
  if (dirty.nome) payload.title = data.nome;
@@ -1217,19 +1682,9 @@ export default function TrainingPage() {
1217
1682
  payload.progressMode = toApiProgressMode(data.progressionMode);
1218
1683
  if (dirty.primaryColor) payload.primaryColor = data.primaryColor;
1219
1684
  if (dirty.secondaryColor) payload.secondaryColor = data.secondaryColor;
1220
- if (itemsChanged) {
1221
- payload.items = orderedItems.map((item, index) => ({
1222
- type: item.type,
1223
- itemId: item.itemId,
1224
- order: index,
1225
- isRequired: item.isRequired !== false,
1226
- }));
1227
- }
1228
-
1229
- if (Object.keys(payload).length === 0) {
1230
- setSheetOpen(false);
1231
- return;
1232
- }
1685
+ if (dirty.bannerFileId) payload.bannerFileId = data.bannerFileId ?? null;
1686
+ if (dirty.certificateTemplateId) payload.certificateTemplateId = data.certificateTemplateId ?? null;
1687
+ payload.modules = modulesPayload;
1233
1688
 
1234
1689
  await request({
1235
1690
  url: `/lms/paths/${editingFormacao.id}`,
@@ -1247,12 +1702,9 @@ export default function TrainingPage() {
1247
1702
  progressMode: toApiProgressMode(data.progressionMode),
1248
1703
  primaryColor: data.primaryColor,
1249
1704
  secondaryColor: data.secondaryColor,
1250
- items: orderedItems.map((item, index) => ({
1251
- type: item.type,
1252
- itemId: item.itemId,
1253
- order: index,
1254
- isRequired: item.isRequired !== false,
1255
- })),
1705
+ bannerFileId: data.bannerFileId ?? null,
1706
+ certificateTemplateId: data.certificateTemplateId ?? null,
1707
+ modules: modulesPayload,
1256
1708
  };
1257
1709
 
1258
1710
  await request({
@@ -1266,6 +1718,7 @@ export default function TrainingPage() {
1266
1718
  await Promise.all([refetchTraining(), refetchStats()]);
1267
1719
  setSheetOpen(false);
1268
1720
  setLearningPathItems([]);
1721
+ setModules([]);
1269
1722
  initialLearningPathRef.current = [];
1270
1723
  } finally {
1271
1724
  setSaving(false);
@@ -1305,7 +1758,12 @@ export default function TrainingPage() {
1305
1758
 
1306
1759
  await refetchCourses();
1307
1760
  if (Number.isFinite(createdCourseId) && createdCourseId > 0) {
1308
- 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
+ }
1309
1767
  setSelectedCourseToAdd('');
1310
1768
  }
1311
1769
 
@@ -1336,7 +1794,12 @@ export default function TrainingPage() {
1336
1794
 
1337
1795
  await refetchExams();
1338
1796
  if (Number.isFinite(createdExamId) && createdExamId > 0) {
1339
- 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
+ }
1340
1803
  setSelectedExamToAdd('');
1341
1804
  }
1342
1805
 
@@ -1500,7 +1963,7 @@ export default function TrainingPage() {
1500
1963
  ],
1501
1964
  },
1502
1965
  ]}
1503
- afterSearchButton={
1966
+ actions={
1504
1967
  <ViewModeToggle
1505
1968
  viewMode={viewMode}
1506
1969
  onViewModeChange={setViewMode}
@@ -1848,7 +2311,7 @@ export default function TrainingPage() {
1848
2311
  setPageSize(nextPageSize);
1849
2312
  setCurrentPage(1);
1850
2313
  }}
1851
- pageSizeOptions={PAGE_SIZES}
2314
+ pageSizeOptions={pageSizeOptions}
1852
2315
  />
1853
2316
  </div>
1854
2317
  )}
@@ -1896,6 +2359,60 @@ export default function TrainingPage() {
1896
2359
  {...form.register('descricao')}
1897
2360
  />
1898
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
+
1899
2416
  <Field>
1900
2417
  <FieldLabel>
1901
2418
  {t('form.fields.progressionMode.label')}{' '}
@@ -2008,6 +2525,39 @@ export default function TrainingPage() {
2008
2525
  />
2009
2526
  </Field>
2010
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
+
2011
2561
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
2012
2562
  <Field>
2013
2563
  <FieldLabel htmlFor="primaryColor">
@@ -2068,120 +2618,63 @@ export default function TrainingPage() {
2068
2618
  </Field>
2069
2619
  </div>
2070
2620
 
2071
- {/* Trilha */}
2621
+ {/* Módulos da trilha */}
2072
2622
  <Field>
2073
- <FieldLabel>{t('form.fields.trilha.label')}</FieldLabel>
2623
+ <FieldLabel>Módulos da trilha</FieldLabel>
2074
2624
  <div className="space-y-2">
2075
- <div className="rounded-md border bg-muted/20 p-3">
2076
- <div className="space-y-2">
2077
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2078
- <Select
2079
- value={selectedCourseToAdd}
2080
- onValueChange={handleCourseSelection}
2081
- disabled={selectableCursos.length === 0}
2082
- >
2083
- <SelectTrigger className="w-full">
2084
- <SelectValue
2085
- placeholder={t('form.fields.cursos.placeholder')}
2086
- />
2087
- </SelectTrigger>
2088
- <SelectContent>
2089
- {selectableCursos.map((course) => (
2090
- <SelectItem
2091
- key={course.id}
2092
- value={String(course.id)}
2093
- >
2094
- <div className="flex items-center gap-2">
2095
- <CourseAvatar
2096
- fileId={course.logoFileId}
2097
- title={course.nome}
2098
- className="size-5 shrink-0 rounded"
2099
- iconSize="size-3"
2100
- />
2101
- <div className="min-w-0">
2102
- <span className="truncate">
2103
- {course.nome}
2104
- </span>
2105
- <span className="ml-1.5 text-xs text-muted-foreground">
2106
- {course.name ?? '—'} · #{course.id}
2107
- </span>
2108
- </div>
2109
- </div>
2110
- </SelectItem>
2111
- ))}
2112
- </SelectContent>
2113
- </Select>
2114
- <Button
2115
- type="button"
2116
- variant="outline"
2117
- className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2118
- onClick={openCreateCourseSheet}
2119
- >
2120
- <Plus className="size-4" />
2121
- </Button>
2122
- </div>
2123
-
2124
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2125
- <Select
2126
- value={selectedExamToAdd}
2127
- onValueChange={handleExamSelection}
2128
- disabled={selectableExams.length === 0}
2129
- >
2130
- <SelectTrigger className="w-full">
2131
- <SelectValue
2132
- placeholder={t('form.fields.exames.placeholder')}
2133
- />
2134
- </SelectTrigger>
2135
- <SelectContent>
2136
- {selectableExams.map((exam) => (
2137
- <SelectItem key={exam.id} value={String(exam.id)}>
2138
- {exam.titulo} ({exam.limiteTempo}min)
2139
- </SelectItem>
2140
- ))}
2141
- </SelectContent>
2142
- </Select>
2143
- <Button
2144
- type="button"
2145
- variant="outline"
2146
- className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2147
- onClick={openCreateExamSheet}
2148
- >
2149
- <Plus className="size-4" />
2150
- </Button>
2151
- </div>
2152
- </div>
2153
- </div>
2154
-
2155
- {trailItems.length > 0 ? (
2156
- <>
2157
- <div className="rounded-md border bg-background">
2158
- <DndContext
2159
- sensors={sensors}
2160
- collisionDetection={closestCenter}
2161
- onDragEnd={handleTrailDragEnd}
2162
- >
2163
- <SortableContext
2164
- items={trailItems.map((item) => item.uid)}
2165
- strategy={verticalListSortingStrategy}
2166
- >
2167
- {trailItems.map((item) => (
2168
- <SortableTrailItem
2169
- key={item.uid}
2170
- item={item}
2171
- onRemove={removeTrailItem}
2172
- />
2173
- ))}
2174
- </SortableContext>
2175
- </DndContext>
2176
- </div>
2177
- </>
2178
- ) : (
2179
- <div className="rounded-md border p-3">
2180
- <p className="text-sm text-muted-foreground">
2181
- {t('form.fields.trilha.empty')}
2182
- </p>
2183
- </div>
2184
- )}
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>
2185
2678
  </div>
2186
2679
 
2187
2680
  {(isFetchingCourses || isFetchingExams) && (
@@ -2189,17 +2682,6 @@ export default function TrainingPage() {
2189
2682
  {t('form.fields.trilha.loading')}
2190
2683
  </p>
2191
2684
  )}
2192
-
2193
- {learningPathItems.length > 0 && (
2194
- <p className="text-xs text-muted-foreground mt-1">
2195
- {learningPathItems.length} {t('coursesSummary.items')}{' '}
2196
- {t('coursesSummary.dot')}{' '}
2197
- {availableCursos
2198
- .filter((c) => selectedCursos.includes(c.id))
2199
- .reduce((a, c) => a + c.cargaHoraria, 0)}
2200
- {t('coursesSummary.hours')}
2201
- </p>
2202
- )}
2203
2685
  </Field>
2204
2686
 
2205
2687
  <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">