@hed-hog/lms 0.0.304 → 0.0.306

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 (458) hide show
  1. package/README.md +413 -401
  2. package/dist/certificate/certificate.controller.d.ts +90 -0
  3. package/dist/certificate/certificate.controller.d.ts.map +1 -0
  4. package/dist/certificate/certificate.controller.js +121 -0
  5. package/dist/certificate/certificate.controller.js.map +1 -0
  6. package/dist/certificate/certificate.module.d.ts +3 -0
  7. package/dist/certificate/certificate.module.d.ts.map +1 -0
  8. package/dist/certificate/certificate.module.js +26 -0
  9. package/dist/certificate/certificate.module.js.map +1 -0
  10. package/dist/certificate/certificate.service.d.ts +115 -0
  11. package/dist/certificate/certificate.service.d.ts.map +1 -0
  12. package/dist/certificate/certificate.service.js +343 -0
  13. package/dist/certificate/certificate.service.js.map +1 -0
  14. package/dist/certificate/dto/create-certificate-template.dto.d.ts +8 -0
  15. package/dist/certificate/dto/create-certificate-template.dto.d.ts.map +1 -0
  16. package/dist/certificate/dto/create-certificate-template.dto.js +44 -0
  17. package/dist/certificate/dto/create-certificate-template.dto.js.map +1 -0
  18. package/dist/certificate/dto/update-certificate-template.dto.d.ts +6 -0
  19. package/dist/certificate/dto/update-certificate-template.dto.d.ts.map +1 -0
  20. package/dist/certificate/dto/update-certificate-template.dto.js +9 -0
  21. package/dist/certificate/dto/update-certificate-template.dto.js.map +1 -0
  22. package/dist/class-group/class-group.controller.d.ts +305 -0
  23. package/dist/class-group/class-group.controller.d.ts.map +1 -0
  24. package/dist/class-group/class-group.controller.js +257 -0
  25. package/dist/class-group/class-group.controller.js.map +1 -0
  26. package/dist/class-group/class-group.module.d.ts +3 -0
  27. package/dist/class-group/class-group.module.d.ts.map +1 -0
  28. package/dist/class-group/class-group.module.js +25 -0
  29. package/dist/class-group/class-group.module.js.map +1 -0
  30. package/dist/class-group/class-group.service.d.ts +354 -0
  31. package/dist/class-group/class-group.service.d.ts.map +1 -0
  32. package/dist/class-group/class-group.service.js +1356 -0
  33. package/dist/class-group/class-group.service.js.map +1 -0
  34. package/dist/class-group/dto/create-class-group.dto.d.ts +33 -0
  35. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -0
  36. package/dist/class-group/dto/create-class-group.dto.js +165 -0
  37. package/dist/class-group/dto/create-class-group.dto.js.map +1 -0
  38. package/dist/class-group/dto/create-session.dto.d.ts +22 -0
  39. package/dist/class-group/dto/create-session.dto.d.ts.map +1 -0
  40. package/dist/class-group/dto/create-session.dto.js +117 -0
  41. package/dist/class-group/dto/create-session.dto.js.map +1 -0
  42. package/dist/class-group/dto/enrollment.dto.d.ts +22 -0
  43. package/dist/class-group/dto/enrollment.dto.d.ts.map +1 -0
  44. package/dist/class-group/dto/enrollment.dto.js +89 -0
  45. package/dist/class-group/dto/enrollment.dto.js.map +1 -0
  46. package/dist/class-group/dto/update-class-group.dto.d.ts +6 -0
  47. package/dist/class-group/dto/update-class-group.dto.d.ts.map +1 -0
  48. package/dist/class-group/dto/update-class-group.dto.js +9 -0
  49. package/dist/class-group/dto/update-class-group.dto.js.map +1 -0
  50. package/dist/class-group/dto/update-session.dto.d.ts +7 -0
  51. package/dist/class-group/dto/update-session.dto.d.ts.map +1 -0
  52. package/dist/class-group/dto/update-session.dto.js +24 -0
  53. package/dist/class-group/dto/update-session.dto.js.map +1 -0
  54. package/dist/course/course-structure.controller.d.ts +127 -0
  55. package/dist/course/course-structure.controller.d.ts.map +1 -0
  56. package/dist/course/course-structure.controller.js +115 -0
  57. package/dist/course/course-structure.controller.js.map +1 -0
  58. package/dist/course/course-structure.service.d.ts +142 -0
  59. package/dist/course/course-structure.service.d.ts.map +1 -0
  60. package/dist/course/course-structure.service.js +445 -0
  61. package/dist/course/course-structure.service.js.map +1 -0
  62. package/dist/course/course.controller.d.ts +195 -0
  63. package/dist/course/course.controller.d.ts.map +1 -0
  64. package/dist/course/course.controller.js +104 -0
  65. package/dist/course/course.controller.js.map +1 -0
  66. package/dist/course/course.module.d.ts +3 -0
  67. package/dist/course/course.module.d.ts.map +1 -0
  68. package/dist/course/course.module.js +28 -0
  69. package/dist/course/course.module.js.map +1 -0
  70. package/dist/course/course.service.d.ts +215 -0
  71. package/dist/course/course.service.d.ts.map +1 -0
  72. package/dist/course/course.service.js +743 -0
  73. package/dist/course/course.service.js.map +1 -0
  74. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +24 -0
  75. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -0
  76. package/dist/course/dto/create-course-structure-lesson.dto.js +118 -0
  77. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -0
  78. package/dist/course/dto/create-course-structure-session.dto.d.ts +7 -0
  79. package/dist/course/dto/create-course-structure-session.dto.d.ts.map +1 -0
  80. package/dist/course/dto/create-course-structure-session.dto.js +40 -0
  81. package/dist/course/dto/create-course-structure-session.dto.js.map +1 -0
  82. package/dist/course/dto/create-course.dto.d.ts +26 -0
  83. package/dist/course/dto/create-course.dto.d.ts.map +1 -0
  84. package/dist/course/dto/create-course.dto.js +138 -0
  85. package/dist/course/dto/create-course.dto.js.map +1 -0
  86. package/dist/course/dto/update-course-structure-lesson.dto.d.ts +6 -0
  87. package/dist/course/dto/update-course-structure-lesson.dto.d.ts.map +1 -0
  88. package/dist/course/dto/update-course-structure-lesson.dto.js +9 -0
  89. package/dist/course/dto/update-course-structure-lesson.dto.js.map +1 -0
  90. package/dist/course/dto/update-course-structure-session.dto.d.ts +6 -0
  91. package/dist/course/dto/update-course-structure-session.dto.d.ts.map +1 -0
  92. package/dist/course/dto/update-course-structure-session.dto.js +9 -0
  93. package/dist/course/dto/update-course-structure-session.dto.js.map +1 -0
  94. package/dist/course/dto/update-course.dto.d.ts +6 -0
  95. package/dist/course/dto/update-course.dto.d.ts.map +1 -0
  96. package/dist/course/dto/update-course.dto.js +9 -0
  97. package/dist/course/dto/update-course.dto.js.map +1 -0
  98. package/dist/dashboard/dashboard.controller.d.ts +101 -0
  99. package/dist/dashboard/dashboard.controller.d.ts.map +1 -0
  100. package/dist/dashboard/dashboard.controller.js +40 -0
  101. package/dist/dashboard/dashboard.controller.js.map +1 -0
  102. package/dist/dashboard/dashboard.module.d.ts +3 -0
  103. package/dist/dashboard/dashboard.module.d.ts.map +1 -0
  104. package/dist/dashboard/dashboard.module.js +25 -0
  105. package/dist/dashboard/dashboard.module.js.map +1 -0
  106. package/dist/dashboard/dashboard.service.d.ts +130 -0
  107. package/dist/dashboard/dashboard.service.d.ts.map +1 -0
  108. package/dist/dashboard/dashboard.service.js +626 -0
  109. package/dist/dashboard/dashboard.service.js.map +1 -0
  110. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts +4 -0
  111. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts.map +1 -0
  112. package/dist/enterprise/dto/add-enterprise-class-group.dto.js +22 -0
  113. package/dist/enterprise/dto/add-enterprise-class-group.dto.js.map +1 -0
  114. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts +5 -0
  115. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts.map +1 -0
  116. package/dist/enterprise/dto/add-enterprise-course.dto.js +27 -0
  117. package/dist/enterprise/dto/add-enterprise-course.dto.js.map +1 -0
  118. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts +5 -0
  119. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  121. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  123. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  125. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  126. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  127. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  129. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  130. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  131. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  133. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  135. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  137. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  139. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  141. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  142. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  143. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  144. package/dist/enterprise/enterprise.controller.js +311 -0
  145. package/dist/enterprise/enterprise.controller.js.map +1 -0
  146. package/dist/enterprise/enterprise.module.d.ts +3 -0
  147. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.module.js +25 -0
  149. package/dist/enterprise/enterprise.module.js.map +1 -0
  150. package/dist/enterprise/enterprise.service.d.ts +282 -0
  151. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.service.js +627 -0
  153. package/dist/enterprise/enterprise.service.js.map +1 -0
  154. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  155. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  156. package/dist/evaluation/evaluation.controller.js +76 -0
  157. package/dist/evaluation/evaluation.controller.js.map +1 -0
  158. package/dist/evaluation/evaluation.module.d.ts +3 -0
  159. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.module.js +25 -0
  161. package/dist/evaluation/evaluation.module.js.map +1 -0
  162. package/dist/evaluation/evaluation.service.d.ts +67 -0
  163. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.service.js +378 -0
  165. package/dist/evaluation/evaluation.service.js.map +1 -0
  166. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  167. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  168. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  169. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  170. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  171. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam.dto.js +63 -0
  173. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  174. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  175. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  177. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  178. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  179. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  181. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  182. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  183. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  185. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  186. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  187. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  191. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  193. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam.dto.js +9 -0
  197. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  198. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  199. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  200. package/dist/exam/exam-attempt.controller.js +84 -0
  201. package/dist/exam/exam-attempt.controller.js.map +1 -0
  202. package/dist/exam/exam-attempt.service.d.ts +302 -0
  203. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.service.js +776 -0
  205. package/dist/exam/exam-attempt.service.js.map +1 -0
  206. package/dist/exam/exam.controller.d.ts +162 -0
  207. package/dist/exam/exam.controller.d.ts.map +1 -0
  208. package/dist/exam/exam.controller.js +158 -0
  209. package/dist/exam/exam.controller.js.map +1 -0
  210. package/dist/exam/exam.module.d.ts +3 -0
  211. package/dist/exam/exam.module.d.ts.map +1 -0
  212. package/dist/exam/exam.module.js +27 -0
  213. package/dist/exam/exam.module.js.map +1 -0
  214. package/dist/exam/exam.service.d.ts +179 -0
  215. package/dist/exam/exam.service.d.ts.map +1 -0
  216. package/dist/exam/exam.service.js +597 -0
  217. package/dist/exam/exam.service.js.map +1 -0
  218. package/dist/index.d.ts +28 -0
  219. package/dist/index.d.ts.map +1 -1
  220. package/dist/index.js +28 -0
  221. package/dist/index.js.map +1 -1
  222. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  223. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  224. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  225. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  226. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  227. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  229. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  230. package/dist/instructor/instructor.controller.d.ts +52 -0
  231. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  232. package/dist/instructor/instructor.controller.js +98 -0
  233. package/dist/instructor/instructor.controller.js.map +1 -0
  234. package/dist/instructor/instructor.module.d.ts +3 -0
  235. package/dist/instructor/instructor.module.d.ts.map +1 -0
  236. package/dist/instructor/instructor.module.js +25 -0
  237. package/dist/instructor/instructor.module.js.map +1 -0
  238. package/dist/instructor/instructor.service.d.ts +79 -0
  239. package/dist/instructor/instructor.service.d.ts.map +1 -0
  240. package/dist/instructor/instructor.service.js +528 -0
  241. package/dist/instructor/instructor.service.js.map +1 -0
  242. package/dist/lms.module.d.ts.map +1 -1
  243. package/dist/lms.module.js +36 -4
  244. package/dist/lms.module.js.map +1 -1
  245. package/dist/reports/reports.controller.d.ts +69 -0
  246. package/dist/reports/reports.controller.d.ts.map +1 -0
  247. package/dist/reports/reports.controller.js +40 -0
  248. package/dist/reports/reports.controller.js.map +1 -0
  249. package/dist/reports/reports.module.d.ts +3 -0
  250. package/dist/reports/reports.module.d.ts.map +1 -0
  251. package/dist/reports/reports.module.js +25 -0
  252. package/dist/reports/reports.module.js.map +1 -0
  253. package/dist/reports/reports.service.d.ts +80 -0
  254. package/dist/reports/reports.service.d.ts.map +1 -0
  255. package/dist/reports/reports.service.js +366 -0
  256. package/dist/reports/reports.service.js.map +1 -0
  257. package/dist/training/dto/create-training.dto.d.ts +19 -0
  258. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  259. package/dist/training/dto/create-training.dto.js +98 -0
  260. package/dist/training/dto/create-training.dto.js.map +1 -0
  261. package/dist/training/dto/update-training.dto.d.ts +6 -0
  262. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/update-training.dto.js +9 -0
  264. package/dist/training/dto/update-training.dto.js.map +1 -0
  265. package/dist/training/training.controller.d.ts +195 -0
  266. package/dist/training/training.controller.d.ts.map +1 -0
  267. package/dist/training/training.controller.js +104 -0
  268. package/dist/training/training.controller.js.map +1 -0
  269. package/dist/training/training.module.d.ts +3 -0
  270. package/dist/training/training.module.d.ts.map +1 -0
  271. package/dist/training/training.module.js +25 -0
  272. package/dist/training/training.module.js.map +1 -0
  273. package/dist/training/training.service.d.ts +213 -0
  274. package/dist/training/training.service.d.ts.map +1 -0
  275. package/dist/training/training.service.js +497 -0
  276. package/dist/training/training.service.js.map +1 -0
  277. package/hedhog/data/dashboard.yaml +6 -0
  278. package/hedhog/data/dashboard_component.yaml +153 -0
  279. package/hedhog/data/dashboard_component_role.yaml +97 -0
  280. package/hedhog/data/dashboard_item.yaml +167 -0
  281. package/hedhog/data/dashboard_role.yaml +6 -0
  282. package/hedhog/data/instructor_qualification.yaml +16 -0
  283. package/hedhog/data/menu.yaml +129 -19
  284. package/hedhog/data/role.yaml +25 -1
  285. package/hedhog/data/route.yaml +867 -0
  286. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  287. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  288. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  289. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  290. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  291. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  292. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  293. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  294. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  295. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  296. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  297. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  298. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  299. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  300. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  301. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  302. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  303. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  304. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  305. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  306. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  307. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  308. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  309. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  310. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  311. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  312. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  313. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  322. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  323. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  324. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  325. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  326. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  327. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  328. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  329. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +58 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +224 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  348. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  349. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  350. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  351. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  352. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  353. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  354. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  355. package/hedhog/frontend/app/page.tsx.ejs +3 -1220
  356. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  357. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  358. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  359. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  360. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  361. package/hedhog/frontend/messages/en.json +1606 -111
  362. package/hedhog/frontend/messages/pt.json +1636 -134
  363. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  364. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  365. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  366. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  367. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  368. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  369. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  370. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  371. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  372. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  373. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  374. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  375. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  376. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  377. package/hedhog/table/course.yaml +19 -1
  378. package/hedhog/table/course_class_group.yaml +8 -0
  379. package/hedhog/table/course_class_session.yaml +33 -0
  380. package/hedhog/table/course_instructor.yaml +27 -0
  381. package/hedhog/table/enterprise.yaml +29 -0
  382. package/hedhog/table/enterprise_class_group.yaml +20 -0
  383. package/hedhog/table/enterprise_course.yaml +23 -0
  384. package/hedhog/table/enterprise_student.yaml +24 -0
  385. package/hedhog/table/enterprise_user.yaml +35 -0
  386. package/hedhog/table/instructor_qualification.yaml +26 -0
  387. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  388. package/hedhog/table/question.yaml +6 -0
  389. package/package.json +6 -6
  390. package/src/certificate/certificate.controller.ts +83 -0
  391. package/src/certificate/certificate.module.ts +13 -0
  392. package/src/certificate/certificate.service.ts +413 -0
  393. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  394. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  395. package/src/class-group/class-group.controller.ts +189 -0
  396. package/src/class-group/class-group.module.ts +12 -0
  397. package/src/class-group/class-group.service.ts +1802 -0
  398. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  399. package/src/class-group/dto/create-session.dto.ts +102 -0
  400. package/src/class-group/dto/enrollment.dto.ts +70 -0
  401. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  402. package/src/class-group/dto/update-session.dto.ts +9 -0
  403. package/src/course/course-structure.controller.ts +85 -0
  404. package/src/course/course-structure.service.ts +525 -0
  405. package/src/course/course.controller.ts +69 -0
  406. package/src/course/course.module.ts +15 -0
  407. package/src/course/course.service.ts +920 -0
  408. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  409. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  410. package/src/course/dto/create-course.dto.ts +111 -0
  411. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  412. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  413. package/src/course/dto/update-course.dto.ts +4 -0
  414. package/src/dashboard/dashboard.controller.ts +14 -0
  415. package/src/dashboard/dashboard.module.ts +12 -0
  416. package/src/dashboard/dashboard.service.ts +726 -0
  417. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  418. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  419. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  420. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  421. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  422. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  423. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  424. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  425. package/src/enterprise/enterprise.controller.ts +233 -0
  426. package/src/enterprise/enterprise.module.ts +12 -0
  427. package/src/enterprise/enterprise.service.ts +712 -0
  428. package/src/evaluation/evaluation.controller.ts +44 -0
  429. package/src/evaluation/evaluation.module.ts +12 -0
  430. package/src/evaluation/evaluation.service.ts +394 -0
  431. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  432. package/src/exam/dto/create-exam.dto.ts +41 -0
  433. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  434. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  435. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  436. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  437. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  438. package/src/exam/dto/update-exam.dto.ts +4 -0
  439. package/src/exam/exam-attempt.controller.ts +65 -0
  440. package/src/exam/exam-attempt.service.ts +1008 -0
  441. package/src/exam/exam.controller.ts +102 -0
  442. package/src/exam/exam.module.ts +14 -0
  443. package/src/exam/exam.service.ts +784 -0
  444. package/src/index.ts +29 -0
  445. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  446. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  447. package/src/instructor/instructor.controller.ts +73 -0
  448. package/src/instructor/instructor.module.ts +12 -0
  449. package/src/instructor/instructor.service.ts +646 -0
  450. package/src/lms.module.ts +36 -4
  451. package/src/reports/reports.controller.ts +14 -0
  452. package/src/reports/reports.module.ts +12 -0
  453. package/src/reports/reports.service.ts +485 -0
  454. package/src/training/dto/create-training.dto.ts +81 -0
  455. package/src/training/dto/update-training.dto.ts +4 -0
  456. package/src/training/training.controller.ts +68 -0
  457. package/src/training/training.module.ts +12 -0
  458. package/src/training/training.service.ts +574 -0
@@ -1,15 +1,23 @@
1
1
  'use client';
2
2
 
3
+ import {
4
+ CourseCategoryOption,
5
+ CourseFormSheet,
6
+ CourseSheetFormValues,
7
+ DEFAULT_COURSE_FORM_VALUES,
8
+ getCourseSheetSchema,
9
+ } from '@/app/(app)/(libraries)/lms/_components/course-form-sheet';
3
10
  import {
4
11
  EmptyState,
5
12
  Page,
6
13
  PageHeader,
7
14
  PaginationFooter,
15
+ SearchBar,
16
+ ViewModeToggle,
8
17
  } from '@/components/entity-list';
9
18
  import { Badge } from '@/components/ui/badge';
10
19
  import { Button } from '@/components/ui/button';
11
20
  import { Card, CardContent } from '@/components/ui/card';
12
- import { Checkbox } from '@/components/ui/checkbox';
13
21
  import {
14
22
  Dialog,
15
23
  DialogContent,
@@ -27,6 +35,7 @@ import {
27
35
  } from '@/components/ui/dropdown-menu';
28
36
  import { Field, FieldError, FieldLabel } from '@/components/ui/field';
29
37
  import { Input } from '@/components/ui/input';
38
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
30
39
  import {
31
40
  Select,
32
41
  SelectContent,
@@ -44,47 +53,59 @@ import {
44
53
  SheetTitle,
45
54
  } from '@/components/ui/sheet';
46
55
  import { Skeleton } from '@/components/ui/skeleton';
56
+ import { Switch } from '@/components/ui/switch';
57
+ import {
58
+ Table,
59
+ TableBody,
60
+ TableCell,
61
+ TableHead,
62
+ TableHeader,
63
+ TableRow,
64
+ } from '@/components/ui/table';
47
65
  import { Textarea } from '@/components/ui/textarea';
66
+ import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
67
+ import {
68
+ DndContext,
69
+ DragEndEvent,
70
+ KeyboardSensor,
71
+ PointerSensor,
72
+ closestCenter,
73
+ useSensor,
74
+ useSensors,
75
+ } from '@dnd-kit/core';
76
+ import {
77
+ SortableContext,
78
+ arrayMove,
79
+ sortableKeyboardCoordinates,
80
+ useSortable,
81
+ verticalListSortingStrategy,
82
+ } from '@dnd-kit/sortable';
83
+ import { CSS } from '@dnd-kit/utilities';
84
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
48
85
  import { zodResolver } from '@hookform/resolvers/zod';
49
86
  import { motion } from 'framer-motion';
50
87
  import {
51
88
  AlertTriangle,
52
- BarChart3,
53
- BookOpen,
54
89
  Clock,
55
- Eye,
56
- FileCheck,
57
90
  GraduationCap,
91
+ GripVertical,
58
92
  Layers,
59
- LayoutDashboard,
60
93
  Loader2,
61
94
  MoreHorizontal,
62
95
  Pencil,
63
96
  Plus,
64
- Search,
65
97
  Target,
66
98
  Trash2,
67
99
  Users,
68
100
  X,
69
101
  } from 'lucide-react';
70
102
  import { useTranslations } from 'next-intl';
71
- import { usePathname, useRouter } from 'next/navigation';
103
+ import { useRouter } from 'next/navigation';
72
104
  import { useEffect, useMemo, useRef, useState } from 'react';
73
- import { Controller, useForm } from 'react-hook-form';
105
+ import { Controller, useForm, useWatch } from 'react-hook-form';
74
106
  import { toast } from 'sonner';
75
107
  import { z } from 'zod';
76
108
 
77
- // ── Nav ───────────────────────────────────────────────────────────────────────
78
-
79
- const NAV_ITEMS = [
80
- { label: 'Dashboard', href: '/', icon: LayoutDashboard },
81
- { label: 'Cursos', href: '/cursos', icon: BookOpen },
82
- { label: 'Turmas', href: '/turmas', icon: Users },
83
- { label: 'Exames', href: '/exames', icon: FileCheck },
84
- { label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
85
- { label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
86
- ];
87
-
88
109
  // ── Types ─────────────────────────────────────────────────────────────────────
89
110
 
90
111
  interface Formacao {
@@ -95,10 +116,300 @@ interface Formacao {
95
116
  nivel: string;
96
117
  prerequisitos: string;
97
118
  cursos: string[];
119
+ exams?: string[];
120
+ courseIds: number[];
121
+ examIds?: number[];
122
+ items?: LearningPathItem[];
98
123
  cargaTotal: number;
99
124
  alunos: number;
100
125
  status: 'ativa' | 'rascunho' | 'encerrada';
101
126
  criadoEm: string;
127
+ primaryColor?: string;
128
+ secondaryColor?: string;
129
+ }
130
+
131
+ type TrainingColorPayload = {
132
+ primaryColor?: string | null;
133
+ secondaryColor?: string | null;
134
+ primary_color?: string | null;
135
+ secondary_color?: string | null;
136
+ };
137
+
138
+ interface CursoOption {
139
+ id: number;
140
+ nome: string;
141
+ cargaHoraria: number;
142
+ categories: string[];
143
+ area: string;
144
+ }
145
+
146
+ interface ExameOption {
147
+ id: number;
148
+ titulo: string;
149
+ limiteTempo: number;
150
+ status: 'publicado' | 'rascunho' | 'encerrado';
151
+ }
152
+
153
+ interface LearningPathItem {
154
+ id?: number;
155
+ type: 'course' | 'exam';
156
+ itemId: number;
157
+ order: number;
158
+ isRequired?: boolean;
159
+ }
160
+
161
+ interface TrailRenderableItem {
162
+ uid: string;
163
+ type: 'course' | 'exam';
164
+ itemId: number;
165
+ title: string;
166
+ subtitle: string;
167
+ order: number;
168
+ }
169
+
170
+ type ApiTrainingListResponse = {
171
+ total: number;
172
+ page: number;
173
+ pageSize: number;
174
+ lastPage: number;
175
+ data: Formacao[];
176
+ };
177
+
178
+ type ApiTrainingStatsResponse = {
179
+ totalTraining: number;
180
+ activeTraining: number;
181
+ enrolledStudents: number;
182
+ coveredCourses: number;
183
+ };
184
+
185
+ type ApiCourseListResponse = {
186
+ data: Array<{
187
+ id: number;
188
+ title: string;
189
+ durationHours: number;
190
+ categories?: string[];
191
+ }>;
192
+ };
193
+
194
+ type ApiExamListResponse = {
195
+ data: Array<{
196
+ id: number;
197
+ title: string;
198
+ timeLimit: number;
199
+ status: 'published' | 'draft' | 'closed' | 'archived';
200
+ }>;
201
+ };
202
+
203
+ type ApiCategory = {
204
+ id: number;
205
+ slug: string;
206
+ name: string;
207
+ status?: 'active' | 'inactive';
208
+ };
209
+
210
+ type ApiCategoryList = {
211
+ data: ApiCategory[];
212
+ total: number;
213
+ page: number;
214
+ pageSize: number;
215
+ };
216
+
217
+ type ViewMode = 'cards' | 'list';
218
+
219
+ type Locale = {
220
+ id?: number;
221
+ code: string;
222
+ name: string;
223
+ };
224
+
225
+ const createExamQuickSchema = z.object({
226
+ titulo: z.string().min(3, 'Minimo 3 caracteres'),
227
+ notaMinima: z.coerce.number().min(0).max(10),
228
+ limiteTempo: z.coerce.number().min(1),
229
+ shuffle: z.boolean().default(false),
230
+ status: z.enum(['rascunho', 'publicado', 'encerrado']),
231
+ });
232
+
233
+ type ExamQuickForm = z.infer<typeof createExamQuickSchema>;
234
+
235
+ function normalizeText(value: string) {
236
+ return value
237
+ .trim()
238
+ .toLowerCase()
239
+ .normalize('NFD')
240
+ .replace(/[\u0300-\u036f]/g, '');
241
+ }
242
+
243
+ function categorySlugToArea(slugs: string[] = []) {
244
+ const normalized = slugs.map((slug) => normalizeText(slug));
245
+
246
+ if (
247
+ normalized.some(
248
+ (slug) =>
249
+ slug === 'design' ||
250
+ slug.includes('design') ||
251
+ slug.includes('ux') ||
252
+ slug.includes('ui')
253
+ )
254
+ ) {
255
+ return 'Design';
256
+ }
257
+ if (
258
+ normalized.some(
259
+ (slug) =>
260
+ slug === 'gestao' ||
261
+ slug === 'management' ||
262
+ slug.includes('gestao') ||
263
+ slug.includes('management')
264
+ )
265
+ ) {
266
+ return 'Gestao';
267
+ }
268
+ if (
269
+ normalized.some(
270
+ (slug) => slug === 'marketing' || slug.includes('marketing')
271
+ )
272
+ ) {
273
+ return 'Marketing';
274
+ }
275
+ if (
276
+ normalized.some(
277
+ (slug) =>
278
+ slug === 'financas' ||
279
+ slug === 'finance' ||
280
+ slug.includes('financ') ||
281
+ slug.includes('accounting')
282
+ )
283
+ ) {
284
+ return 'Financas';
285
+ }
286
+
287
+ return 'Tecnologia';
288
+ }
289
+
290
+ function normalizeAreaValue(value: string) {
291
+ const normalized = normalizeText(value);
292
+
293
+ if (
294
+ normalized === 'tecnologia' ||
295
+ normalized === 'technology' ||
296
+ normalized.includes('tecnolog') ||
297
+ normalized.includes('technology')
298
+ ) {
299
+ return 'Tecnologia';
300
+ }
301
+ if (normalized === 'design' || normalized.includes('design')) return 'Design';
302
+ if (
303
+ normalized === 'gestao' ||
304
+ normalized === 'management' ||
305
+ normalized.includes('gestao') ||
306
+ normalized.includes('management')
307
+ ) {
308
+ return 'Gestao';
309
+ }
310
+ if (normalized === 'marketing' || normalized.includes('marketing')) {
311
+ return 'Marketing';
312
+ }
313
+ if (
314
+ normalized === 'financas' ||
315
+ normalized === 'finance' ||
316
+ normalized.includes('financ') ||
317
+ normalized.includes('accounting')
318
+ ) {
319
+ return 'Financas';
320
+ }
321
+
322
+ return 'Tecnologia';
323
+ }
324
+
325
+ function normalizeLevelValue(value: string) {
326
+ const normalized = normalizeText(value);
327
+
328
+ if (['iniciante', 'beginner'].includes(normalized)) return 'Iniciante';
329
+ if (['intermediario', 'intermediate'].includes(normalized)) {
330
+ return 'Intermediario';
331
+ }
332
+ if (['avancado', 'advanced'].includes(normalized)) return 'Avancado';
333
+
334
+ return 'Iniciante';
335
+ }
336
+
337
+ function normalizeStatusValue(value: string) {
338
+ const normalized = normalizeText(value);
339
+
340
+ if (['ativa', 'active'].includes(normalized)) return 'ativa';
341
+ if (['encerrada', 'archived'].includes(normalized)) return 'encerrada';
342
+
343
+ return 'rascunho';
344
+ }
345
+
346
+ function slugifyText(value: string) {
347
+ return normalizeText(value)
348
+ .replace(/[^a-z0-9\s-]/g, '')
349
+ .replace(/\s+/g, '-')
350
+ .replace(/-+/g, '-')
351
+ .replace(/^-|-$/g, '')
352
+ .slice(0, 64);
353
+ }
354
+
355
+ function normalizeHexColor(value?: string | null) {
356
+ if (!value) return null;
357
+
358
+ const raw = value.trim();
359
+ if (!raw) return null;
360
+
361
+ const prefixed = raw.startsWith('#') ? raw : `#${raw}`;
362
+
363
+ if (/^#([0-9A-Fa-f]{6})$/.test(prefixed)) return prefixed;
364
+ if (/^#([0-9A-Fa-f]{3})$/.test(prefixed)) {
365
+ const hex = prefixed.slice(1);
366
+
367
+ return `#${hex
368
+ .split('')
369
+ .map((char) => `${char}${char}`)
370
+ .join('')}`;
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ function normalizeTrainingColorPayload<T extends TrainingColorPayload>(
377
+ payload: T
378
+ ) {
379
+ const primaryColor = normalizeHexColor(
380
+ payload.primaryColor ?? payload.primary_color
381
+ );
382
+ const secondaryColor = normalizeHexColor(
383
+ payload.secondaryColor ?? payload.secondary_color
384
+ );
385
+
386
+ return {
387
+ ...payload,
388
+ primaryColor: primaryColor ?? undefined,
389
+ secondaryColor: secondaryColor ?? undefined,
390
+ };
391
+ }
392
+
393
+ function normalizeTrainingListPayload(
394
+ payload: ApiTrainingListResponse
395
+ ): ApiTrainingListResponse {
396
+ return {
397
+ ...payload,
398
+ data: (payload.data ?? []).map((item) =>
399
+ normalizeTrainingColorPayload(item as Formacao & TrainingColorPayload)
400
+ ),
401
+ };
402
+ }
403
+
404
+ function buildCourseCodeFromTitle(title: string) {
405
+ const base = slugifyText(title)
406
+ .toUpperCase()
407
+ .replace(/[^A-Z0-9]/g, '-');
408
+ const compact = base.replace(/-+/g, '-').replace(/^-|-$/g, '');
409
+
410
+ if (compact.length >= 2) return compact.slice(0, 32);
411
+
412
+ return `COURSE-${Date.now().toString().slice(-6)}`;
102
413
  }
103
414
 
104
415
  // ── Schema ────────────────────────────────────────────────────────────────────
@@ -106,10 +417,18 @@ interface Formacao {
106
417
  const formacaoSchema = z.object({
107
418
  nome: z.string().min(3, 'Minimo 3 caracteres'),
108
419
  descricao: z.string().min(10, 'Minimo 10 caracteres'),
109
- area: z.string().min(1, 'Selecione uma area'),
110
- nivel: z.string().min(1, 'Selecione um nivel'),
420
+ area: z.enum(['Tecnologia', 'Design', 'Gestao', 'Marketing', 'Financas']),
421
+ nivel: z.enum(['Iniciante', 'Intermediario', 'Avancado']),
111
422
  prerequisitos: z.string().optional(),
112
- status: z.string().min(1, 'Selecione um status'),
423
+ status: z.enum(['rascunho', 'ativa', 'encerrada']),
424
+ primaryColor: z
425
+ .string()
426
+ .regex(/^#([0-9A-Fa-f]{6})$/, 'Cor primária inválida')
427
+ .default('#1D4ED8'),
428
+ secondaryColor: z
429
+ .string()
430
+ .regex(/^#([0-9A-Fa-f]{6})$/, 'Cor secundária inválida')
431
+ .default('#111827'),
113
432
  });
114
433
 
115
434
  type FormacaoForm = z.infer<typeof formacaoSchema>;
@@ -125,155 +444,8 @@ const STATUS_MAP: Record<
125
444
  encerrada: { label: 'Encerrada', variant: 'outline' },
126
445
  };
127
446
 
128
- const AREA_COLORS: Record<string, string> = {
129
- Tecnologia: 'bg-blue-50 text-blue-700 border-blue-200',
130
- Design: 'bg-purple-50 text-purple-700 border-purple-200',
131
- Gestao: 'bg-amber-50 text-amber-700 border-amber-200',
132
- Marketing: 'bg-orange-50 text-orange-700 border-orange-200',
133
- Financas: 'bg-emerald-50 text-emerald-700 border-emerald-200',
134
- };
135
-
136
447
  const PAGE_SIZES = [6, 12, 24];
137
-
138
- const availableCursos = [
139
- { id: 'react', nome: 'React Avancado', cargaHoraria: 60 },
140
- { id: 'ux', nome: 'UX Design Fundamentals', cargaHoraria: 40 },
141
- { id: 'python', nome: 'Python para Data Science', cargaHoraria: 80 },
142
- { id: 'node', nome: 'Node.js Completo', cargaHoraria: 70 },
143
- { id: 'typescript', nome: 'TypeScript na Pratica', cargaHoraria: 50 },
144
- { id: 'figma', nome: 'Figma para Iniciantes', cargaHoraria: 25 },
145
- { id: 'agile', nome: 'Gestao de Projetos Ageis', cargaHoraria: 30 },
146
- { id: 'marketing', nome: 'Marketing Digital', cargaHoraria: 45 },
147
- { id: 'design-system', nome: 'Design System', cargaHoraria: 35 },
148
- { id: 'excel', nome: 'Excel para Negocios', cargaHoraria: 30 },
149
- { id: 'lideranca', nome: 'Lideranca e Comunicacao', cargaHoraria: 20 },
150
- { id: 'seo', nome: 'SEO Avancado', cargaHoraria: 35 },
151
- ];
152
-
153
- // ── Seed Data ─────────────────────────────────────────────────────────────────
154
-
155
- const initialFormacoes: Formacao[] = [
156
- {
157
- id: 1,
158
- nome: 'Full Stack Developer',
159
- descricao:
160
- 'Formacao completa para desenvolvimento full stack com React, Node.js e TypeScript.',
161
- area: 'Tecnologia',
162
- nivel: 'Avancado',
163
- prerequisitos: 'JavaScript basico',
164
- cursos: ['React Avancado', 'Node.js Completo', 'TypeScript na Pratica'],
165
- cargaTotal: 180,
166
- alunos: 342,
167
- status: 'ativa',
168
- criadoEm: '2024-01-01',
169
- },
170
- {
171
- id: 2,
172
- nome: 'UX/UI Designer Profissional',
173
- descricao:
174
- 'Torne-se um designer completo com habilidades em UX, UI e design systems.',
175
- area: 'Design',
176
- nivel: 'Intermediario',
177
- prerequisitos: 'Nenhum',
178
- cursos: [
179
- 'UX Design Fundamentals',
180
- 'Figma para Iniciantes',
181
- 'Design System',
182
- ],
183
- cargaTotal: 100,
184
- alunos: 198,
185
- status: 'ativa',
186
- criadoEm: '2024-01-15',
187
- },
188
- {
189
- id: 3,
190
- nome: 'Data Science com Python',
191
- descricao:
192
- 'Domine ciencia de dados desde fundamentos ate machine learning com Python.',
193
- area: 'Tecnologia',
194
- nivel: 'Intermediario',
195
- prerequisitos: 'Logica de programacao',
196
- cursos: ['Python para Data Science', 'Excel para Negocios'],
197
- cargaTotal: 110,
198
- alunos: 267,
199
- status: 'ativa',
200
- criadoEm: '2024-02-01',
201
- },
202
- {
203
- id: 4,
204
- nome: 'Gestao e Lideranca',
205
- descricao:
206
- 'Formacao em gestao de projetos ageis e habilidades de lideranca para times modernos.',
207
- area: 'Gestao',
208
- nivel: 'Iniciante',
209
- prerequisitos: 'Nenhum',
210
- cursos: [
211
- 'Gestao de Projetos Ageis',
212
- 'Lideranca e Comunicacao',
213
- 'Excel para Negocios',
214
- ],
215
- cargaTotal: 80,
216
- alunos: 156,
217
- status: 'ativa',
218
- criadoEm: '2024-02-15',
219
- },
220
- {
221
- id: 5,
222
- nome: 'Marketing Digital Completo',
223
- descricao:
224
- 'Domine estrategias de marketing digital, SEO e conteudo para web.',
225
- area: 'Marketing',
226
- nivel: 'Intermediario',
227
- prerequisitos: 'Nenhum',
228
- cursos: ['Marketing Digital', 'SEO Avancado'],
229
- cargaTotal: 80,
230
- alunos: 0,
231
- status: 'rascunho',
232
- criadoEm: '2024-03-01',
233
- },
234
- {
235
- id: 6,
236
- nome: 'Frontend Developer',
237
- descricao:
238
- 'Especializacao em desenvolvimento frontend com as tecnologias mais atuais do mercado.',
239
- area: 'Tecnologia',
240
- nivel: 'Intermediario',
241
- prerequisitos: 'HTML, CSS e JS basico',
242
- cursos: ['React Avancado', 'TypeScript na Pratica', 'Design System'],
243
- cargaTotal: 145,
244
- alunos: 89,
245
- status: 'ativa',
246
- criadoEm: '2024-03-15',
247
- },
248
- {
249
- id: 7,
250
- nome: 'Design Thinking e Inovacao',
251
- descricao:
252
- 'Aprenda metodologias de design thinking e processos de inovacao para negócios.',
253
- area: 'Design',
254
- nivel: 'Iniciante',
255
- prerequisitos: 'Nenhum',
256
- cursos: ['UX Design Fundamentals', 'Gestao de Projetos Ageis'],
257
- cargaTotal: 70,
258
- alunos: 412,
259
- status: 'encerrada',
260
- criadoEm: '2023-09-01',
261
- },
262
- {
263
- id: 8,
264
- nome: 'Analista de Dados',
265
- descricao:
266
- 'Formacao completa para analise de dados empresariais com ferramentas modernas.',
267
- area: 'Tecnologia',
268
- nivel: 'Iniciante',
269
- prerequisitos: 'Nenhum',
270
- cursos: ['Excel para Negocios', 'Python para Data Science'],
271
- cargaTotal: 110,
272
- alunos: 203,
273
- status: 'ativa',
274
- criadoEm: '2024-04-01',
275
- },
276
- ];
448
+ const API_TRAINING_CACHE_KEY = 'lms:training:api-cache';
277
449
 
278
450
  // ── Animations ────────────────────────────────────────────────────────────────
279
451
 
@@ -290,34 +462,101 @@ const stagger = {
290
462
  show: { transition: { staggerChildren: 0.05 } },
291
463
  };
292
464
 
465
+ function SortableTrailItem(props: {
466
+ item: TrailRenderableItem;
467
+ onRemove: (uid: string) => void;
468
+ }) {
469
+ const { item, onRemove } = props;
470
+ const { attributes, listeners, setNodeRef, transform, transition } =
471
+ useSortable({ id: item.uid });
472
+
473
+ const style = {
474
+ transform: CSS.Transform.toString(transform),
475
+ transition,
476
+ };
477
+
478
+ return (
479
+ <div
480
+ ref={setNodeRef}
481
+ style={style}
482
+ className="flex items-center gap-2 border-b p-2.5 last:border-0"
483
+ >
484
+ <Button
485
+ type="button"
486
+ variant="ghost"
487
+ size="icon"
488
+ className="size-7 text-muted-foreground"
489
+ aria-label="Arrastar item"
490
+ {...attributes}
491
+ {...listeners}
492
+ >
493
+ <GripVertical className="size-4" />
494
+ </Button>
495
+
496
+ <div className="min-w-0 flex-1">
497
+ <p className="truncate text-sm font-medium">{item.title}</p>
498
+ <p className="truncate text-xs text-muted-foreground">
499
+ {item.subtitle}
500
+ </p>
501
+ </div>
502
+
503
+ <Badge variant="outline" className="text-[10px] uppercase tracking-wide">
504
+ {item.type === 'course' ? 'Curso' : 'Exame'}
505
+ </Badge>
506
+
507
+ <Button
508
+ type="button"
509
+ variant="ghost"
510
+ size="sm"
511
+ onClick={() => onRemove(item.uid)}
512
+ className="h-7 px-2 text-muted-foreground"
513
+ >
514
+ <X className="size-3.5" />
515
+ </Button>
516
+ </div>
517
+ );
518
+ }
519
+
293
520
  // ── Page ──────────────────────────────────────────────────────────────────────
294
521
 
295
522
  export default function TrainingPage() {
296
523
  const t = useTranslations('lms.TrainingPage');
297
- const pathname = usePathname();
524
+ const tCourse = useTranslations('lms.CoursesPage');
525
+ const tExam = useTranslations('lms.ExamsPage');
298
526
  const router = useRouter();
527
+ const { request } = useApp();
299
528
 
300
- const [loading, setLoading] = useState(true);
301
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
302
- const [formacoes, setFormacoes] = useState<Formacao[]>(initialFormacoes);
303
529
  const [sheetOpen, setSheetOpen] = useState(false);
304
530
  const [editingFormacao, setEditingFormacao] = useState<Formacao | null>(null);
305
531
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
306
532
  const [formacaoToDelete, setFormacaoToDelete] = useState<Formacao | null>(
307
533
  null
308
534
  );
309
- const [selectedCursos, setSelectedCursos] = useState<string[]>([]);
535
+ const [learningPathItems, setLearningPathItems] = useState<
536
+ LearningPathItem[]
537
+ >([]);
538
+ const [selectedCourseToAdd, setSelectedCourseToAdd] = useState('');
539
+ const [selectedExamToAdd, setSelectedExamToAdd] = useState('');
310
540
  const [saving, setSaving] = useState(false);
541
+ const [loadingEditSheet, setLoadingEditSheet] = useState(false);
542
+ const [courseSheetOpen, setCourseSheetOpen] = useState(false);
543
+ const [examSheetOpen, setExamSheetOpen] = useState(false);
544
+ const [creatingCourse, setCreatingCourse] = useState(false);
545
+ const [creatingExam, setCreatingExam] = useState(false);
546
+ const [cachedListData, setCachedListData] =
547
+ useState<ApiTrainingListResponse | null>(null);
548
+ const initialLearningPathRef = useRef<LearningPathItem[]>([]);
311
549
 
312
550
  // Search/filter inputs
313
551
  const [buscaInput, setBuscaInput] = useState('');
314
- const [filtroAreaInput, setFiltroAreaInput] = useState('todos');
552
+ const [buscaDebounced, setBuscaDebounced] = useState('');
553
+ const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
315
554
  const [filtroNivelInput, setFiltroNivelInput] = useState('todos');
316
-
317
- // Applied filters
318
- const [buscaApplied, setBuscaApplied] = useState('');
319
- const [filtroAreaApplied, setFiltroAreaApplied] = useState('todos');
320
- const [filtroNivelApplied, setFiltroNivelApplied] = useState('todos');
555
+ const [viewMode, setViewMode] = usePersistedViewMode<ViewMode>({
556
+ storageKey: 'lms:training:view-mode',
557
+ defaultValue: 'cards',
558
+ allowedValues: ['cards', 'list'],
559
+ });
321
560
 
322
561
  // Pagination
323
562
  const [currentPage, setCurrentPage] = useState(1);
@@ -328,72 +567,367 @@ export default function TrainingPage() {
328
567
  new Map()
329
568
  );
330
569
 
570
+ const sensors = useSensors(
571
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
572
+ useSensor(KeyboardSensor, {
573
+ coordinateGetter: sortableKeyboardCoordinates,
574
+ })
575
+ );
576
+
577
+ const {
578
+ data: listData,
579
+ isLoading: isLoadingList,
580
+ isFetching: isFetchingList,
581
+ refetch: refetchTraining,
582
+ } = useQuery<ApiTrainingListResponse>({
583
+ queryKey: [
584
+ 'lms-training-list',
585
+ currentPage,
586
+ pageSize,
587
+ buscaDebounced,
588
+ filtroStatusInput,
589
+ filtroNivelInput,
590
+ ],
591
+ queryFn: async () => {
592
+ const response = await request<ApiTrainingListResponse>({
593
+ url: '/lms/training',
594
+ method: 'GET',
595
+ params: {
596
+ page: currentPage,
597
+ pageSize,
598
+ search: buscaDebounced || undefined,
599
+ status:
600
+ filtroStatusInput !== 'todos'
601
+ ? ptStatusToApi(filtroStatusInput as Formacao['status'])
602
+ : undefined,
603
+ level: filtroNivelInput !== 'todos' ? filtroNivelInput : undefined,
604
+ },
605
+ });
606
+
607
+ return normalizeTrainingListPayload(response.data);
608
+ },
609
+ });
610
+
611
+ const { data: statsData, refetch: refetchStats } =
612
+ useQuery<ApiTrainingStatsResponse>({
613
+ queryKey: ['lms-training-stats'],
614
+ queryFn: async () => {
615
+ const response = await request<ApiTrainingStatsResponse>({
616
+ url: '/lms/training/stats',
617
+ method: 'GET',
618
+ });
619
+
620
+ return response.data;
621
+ },
622
+ });
623
+
624
+ const {
625
+ data: coursesData,
626
+ refetch: refetchCourses,
627
+ isFetching: isFetchingCourses,
628
+ } = useQuery<ApiCourseListResponse>({
629
+ queryKey: ['lms-training-course-options'],
630
+ queryFn: async () => {
631
+ const response = await request<ApiCourseListResponse>({
632
+ url: '/lms/courses',
633
+ method: 'GET',
634
+ params: {
635
+ page: 1,
636
+ pageSize: 1000,
637
+ },
638
+ });
639
+
640
+ return response.data;
641
+ },
642
+ });
643
+
644
+ const {
645
+ data: examsData,
646
+ refetch: refetchExams,
647
+ isFetching: isFetchingExams,
648
+ } = useQuery<ApiExamListResponse>({
649
+ queryKey: ['lms-training-exam-options'],
650
+ queryFn: async () => {
651
+ const response = await request<ApiExamListResponse>({
652
+ url: '/lms/exams',
653
+ method: 'GET',
654
+ params: {
655
+ page: 1,
656
+ pageSize: 1000,
657
+ },
658
+ });
659
+
660
+ return response.data;
661
+ },
662
+ });
663
+
664
+ const { data: categoryListData, refetch: refetchCategories } =
665
+ useQuery<ApiCategoryList>({
666
+ queryKey: ['lms-training-category-options'],
667
+ queryFn: async () => {
668
+ const response = await request<ApiCategoryList>({
669
+ url: '/category',
670
+ method: 'GET',
671
+ params: {
672
+ page: 1,
673
+ pageSize: 500,
674
+ status: 'all',
675
+ },
676
+ });
677
+
678
+ const payload = response.data as ApiCategoryList | ApiCategory[];
679
+ if (Array.isArray(payload)) {
680
+ return {
681
+ data: payload,
682
+ total: payload.length,
683
+ page: 1,
684
+ pageSize: payload.length,
685
+ };
686
+ }
687
+
688
+ return payload;
689
+ },
690
+ initialData: {
691
+ data: [],
692
+ total: 0,
693
+ page: 1,
694
+ pageSize: 500,
695
+ },
696
+ });
697
+
698
+ useEffect(() => {
699
+ if (courseSheetOpen) {
700
+ void refetchCategories();
701
+ }
702
+ }, [courseSheetOpen, refetchCategories]);
703
+
704
+ const availableCursos: CursoOption[] = (coursesData?.data ?? []).map(
705
+ (course) => ({
706
+ id: course.id,
707
+ nome: course.title,
708
+ cargaHoraria: course.durationHours ?? 0,
709
+ categories: course.categories ?? [],
710
+ area: categorySlugToArea(course.categories ?? []),
711
+ })
712
+ );
713
+
331
714
  const form = useForm<FormacaoForm>({
332
715
  resolver: zodResolver(formacaoSchema),
333
716
  defaultValues: {
334
717
  nome: '',
335
718
  descricao: '',
336
- area: '',
337
- nivel: '',
719
+ area: 'Tecnologia',
720
+ nivel: 'Iniciante',
338
721
  prerequisitos: '',
339
722
  status: 'rascunho',
723
+ primaryColor: '#1D4ED8',
724
+ secondaryColor: '#111827',
340
725
  },
341
726
  });
342
727
 
343
- useEffect(() => {
344
- const t = setTimeout(() => setLoading(false), 800);
345
- return () => clearTimeout(t);
346
- }, []);
728
+ const courseForm = useForm<CourseSheetFormValues>({
729
+ resolver: zodResolver(getCourseSheetSchema(tCourse)),
730
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
731
+ });
732
+ const examForm = useForm<ExamQuickForm>({
733
+ resolver: zodResolver(createExamQuickSchema),
734
+ defaultValues: {
735
+ titulo: '',
736
+ notaMinima: 7,
737
+ limiteTempo: 60,
738
+ shuffle: false,
739
+ status: 'rascunho',
740
+ },
741
+ });
742
+ const watchedFormValues = useWatch({ control: form.control });
347
743
 
348
- // ── Filtering ────────────────────────────────────────────────────────────
744
+ const availableExams: ExameOption[] = (examsData?.data ?? []).map((exam) => ({
745
+ id: exam.id,
746
+ titulo: exam.title,
747
+ limiteTempo: exam.timeLimit ?? 0,
748
+ status:
749
+ exam.status === 'published'
750
+ ? 'publicado'
751
+ : exam.status === 'draft'
752
+ ? 'rascunho'
753
+ : 'encerrado',
754
+ }));
349
755
 
350
- const filteredFormacoes = useMemo(
756
+ const selectedCursos = useMemo(
351
757
  () =>
352
- formacoes.filter((f) => {
353
- const q = buscaApplied.toLowerCase();
354
- return (
355
- (!q ||
356
- f.nome.toLowerCase().includes(q) ||
357
- f.descricao.toLowerCase().includes(q)) &&
358
- (filtroAreaApplied === 'todos' || f.area === filtroAreaApplied) &&
359
- (filtroNivelApplied === 'todos' || f.nivel === filtroNivelApplied)
360
- );
361
- }),
362
- [formacoes, buscaApplied, filtroAreaApplied, filtroNivelApplied]
758
+ learningPathItems
759
+ .filter((item) => item.type === 'course')
760
+ .map((item) => item.itemId),
761
+ [learningPathItems]
762
+ );
763
+
764
+ const trailItems = useMemo<TrailRenderableItem[]>(() => {
765
+ const sorted = [...learningPathItems].sort((a, b) => a.order - b.order);
766
+
767
+ return sorted
768
+ .map((item, index) => {
769
+ if (item.type === 'course') {
770
+ const course = availableCursos.find((c) => c.id === item.itemId);
771
+ if (!course) return null;
772
+
773
+ return {
774
+ uid: `course-${course.id}`,
775
+ type: 'course' as const,
776
+ itemId: course.id,
777
+ title: course.nome,
778
+ subtitle: `${course.cargaHoraria}h`,
779
+ order: item.order ?? index,
780
+ };
781
+ }
782
+
783
+ const exam = availableExams.find((e) => e.id === item.itemId);
784
+ if (!exam) return null;
785
+
786
+ return {
787
+ uid: `exam-${exam.id}`,
788
+ type: 'exam' as const,
789
+ itemId: exam.id,
790
+ title: exam.titulo,
791
+ subtitle: `${exam.limiteTempo}min`,
792
+ order: item.order ?? index,
793
+ };
794
+ })
795
+ .filter(Boolean) as TrailRenderableItem[];
796
+ }, [availableCursos, availableExams, learningPathItems]);
797
+
798
+ const selectableCursos = useMemo(
799
+ () => [...availableCursos].sort((a, b) => a.nome.localeCompare(b.nome)),
800
+ [availableCursos]
363
801
  );
364
802
 
365
- const totalPages = Math.max(
366
- 1,
367
- Math.ceil(filteredFormacoes.length / pageSize)
803
+ const selectableExams = useMemo(
804
+ () => [...availableExams].sort((a, b) => a.titulo.localeCompare(b.titulo)),
805
+ [availableExams]
368
806
  );
369
- const safePage = Math.min(currentPage, totalPages);
370
- const paginatedFormacoes = filteredFormacoes.slice(
371
- (safePage - 1) * pageSize,
372
- safePage * pageSize
807
+
808
+ const categoryOptions = useMemo<CourseCategoryOption[]>(
809
+ () =>
810
+ (categoryListData?.data ?? [])
811
+ .filter((category) => !!category.slug)
812
+ .map((category) => ({
813
+ value: category.slug,
814
+ label: category.name || category.slug,
815
+ }))
816
+ .sort((a, b) => a.label.localeCompare(b.label)),
817
+ [categoryListData]
373
818
  );
374
819
 
375
- function handleSearch(e: React.FormEvent) {
376
- e.preventDefault();
377
- setBuscaApplied(buscaInput);
378
- setFiltroAreaApplied(filtroAreaInput);
379
- setFiltroNivelApplied(filtroNivelInput);
820
+ useEffect(() => {
821
+ if (typeof window === 'undefined') return;
822
+ try {
823
+ const raw = window.localStorage.getItem(API_TRAINING_CACHE_KEY);
824
+ if (!raw) return;
825
+ const parsed = JSON.parse(raw) as ApiTrainingListResponse;
826
+ if (parsed && Array.isArray(parsed.data)) {
827
+ setCachedListData(normalizeTrainingListPayload(parsed));
828
+ }
829
+ } catch {
830
+ setCachedListData(null);
831
+ }
832
+ }, []);
833
+
834
+ useEffect(() => {
835
+ if (typeof window === 'undefined') return;
836
+ if (!listData) return;
837
+ window.localStorage.setItem(
838
+ API_TRAINING_CACHE_KEY,
839
+ JSON.stringify(listData)
840
+ );
841
+ setCachedListData(listData);
842
+ }, [listData]);
843
+
844
+ const effectiveListData = listData ?? cachedListData;
845
+ const initialLoading = isLoadingList && !effectiveListData;
846
+ const cardsRefreshing = isFetchingList && !!effectiveListData;
847
+
848
+ const formacoesToRender = useMemo(() => {
849
+ const formacoes = effectiveListData?.data ?? [];
850
+
851
+ if (!sheetOpen || !editingFormacao) return formacoes;
852
+
853
+ const selectedCourses = availableCursos.filter((course) =>
854
+ selectedCursos.includes(course.id)
855
+ );
856
+
857
+ const selectedCourseNames = selectedCourses.map((course) => course.nome);
858
+ const selectedCourseHours = selectedCourses.reduce(
859
+ (sum, course) => sum + course.cargaHoraria,
860
+ 0
861
+ );
862
+
863
+ return formacoes.map((formacao) => {
864
+ if (formacao.id !== editingFormacao.id) return formacao;
865
+
866
+ return {
867
+ ...formacao,
868
+ nome: watchedFormValues.nome ?? formacao.nome,
869
+ descricao: watchedFormValues.descricao ?? formacao.descricao,
870
+ nivel: watchedFormValues.nivel ?? formacao.nivel,
871
+ status: watchedFormValues.status ?? formacao.status,
872
+ prerequisitos:
873
+ watchedFormValues.prerequisitos ?? formacao.prerequisitos,
874
+ primaryColor: watchedFormValues.primaryColor ?? formacao.primaryColor,
875
+ secondaryColor:
876
+ watchedFormValues.secondaryColor ?? formacao.secondaryColor,
877
+ cursos: selectedCourseNames,
878
+ courseIds: selectedCursos,
879
+ cargaTotal: selectedCourseHours,
880
+ };
881
+ });
882
+ }, [
883
+ availableCursos,
884
+ editingFormacao,
885
+ effectiveListData,
886
+ selectedCursos,
887
+ sheetOpen,
888
+ watchedFormValues,
889
+ ]);
890
+
891
+ const totalItems = effectiveListData?.total ?? 0;
892
+ const totalPages = Math.max(effectiveListData?.lastPage ?? 1, 1);
893
+ const safePage = Math.min(currentPage, totalPages);
894
+
895
+ useEffect(() => {
896
+ if (currentPage > totalPages) {
897
+ setCurrentPage(totalPages);
898
+ }
899
+ }, [currentPage, totalPages]);
900
+
901
+ useEffect(() => {
902
+ const timeout = setTimeout(() => {
903
+ setBuscaDebounced(buscaInput.trim());
904
+ }, 350);
905
+
906
+ return () => clearTimeout(timeout);
907
+ }, [buscaInput]);
908
+
909
+ useEffect(() => {
380
910
  setCurrentPage(1);
381
- }
911
+ }, [buscaDebounced, filtroStatusInput, filtroNivelInput]);
382
912
 
383
913
  function clearFilters() {
384
914
  setBuscaInput('');
385
- setFiltroAreaInput('todos');
915
+ setBuscaDebounced('');
916
+ setFiltroStatusInput('todos');
386
917
  setFiltroNivelInput('todos');
387
- setBuscaApplied('');
388
- setFiltroAreaApplied('todos');
389
- setFiltroNivelApplied('todos');
390
918
  setCurrentPage(1);
391
919
  }
392
920
 
393
921
  const hasActiveFilters =
394
- buscaApplied ||
395
- filtroAreaApplied !== 'todos' ||
396
- filtroNivelApplied !== 'todos';
922
+ buscaInput.trim().length > 0 ||
923
+ filtroStatusInput !== 'todos' ||
924
+ filtroNivelInput !== 'todos';
925
+
926
+ function openDeleteDialog(formacao: Formacao, e: React.MouseEvent) {
927
+ e.stopPropagation();
928
+ setFormacaoToDelete(formacao);
929
+ setDeleteDialogOpen(true);
930
+ }
397
931
 
398
932
  // ── Double-click ──────────────────────────────────────────────────────────
399
933
 
@@ -416,85 +950,396 @@ export default function TrainingPage() {
416
950
 
417
951
  function openCreateSheet() {
418
952
  setEditingFormacao(null);
419
- setSelectedCursos([]);
953
+ initialLearningPathRef.current = [];
954
+ setLearningPathItems([]);
420
955
  form.reset({
421
956
  nome: '',
422
957
  descricao: '',
423
- area: '',
424
- nivel: '',
958
+ area: 'Tecnologia',
959
+ nivel: 'Iniciante',
425
960
  prerequisitos: '',
426
961
  status: 'rascunho',
962
+ primaryColor: '#1D4ED8',
963
+ secondaryColor: '#111827',
427
964
  });
965
+ examForm.reset();
966
+ setSelectedCourseToAdd('');
967
+ setSelectedExamToAdd('');
968
+ void Promise.all([refetchCourses(), refetchExams(), refetchCategories()]);
428
969
  setSheetOpen(true);
429
970
  }
430
971
 
431
- function openEditSheet(formacao: Formacao, e?: React.MouseEvent) {
972
+ async function openEditSheet(formacao: Formacao, e?: React.MouseEvent) {
432
973
  e?.stopPropagation();
433
- setEditingFormacao(formacao);
434
- setSelectedCursos(formacao.cursos);
435
- form.reset({
436
- nome: formacao.nome,
437
- descricao: formacao.descricao,
438
- area: formacao.area,
439
- nivel: formacao.nivel,
440
- prerequisitos: formacao.prerequisitos,
441
- status: formacao.status,
974
+ setLoadingEditSheet(true);
975
+ try {
976
+ const [response] = await Promise.all([
977
+ request<Formacao>({
978
+ url: `/lms/training/${formacao.id}`,
979
+ method: 'GET',
980
+ }),
981
+ refetchCourses(),
982
+ refetchExams(),
983
+ refetchCategories(),
984
+ ]);
985
+
986
+ const fullFormacao = normalizeTrainingColorPayload(
987
+ (response?.data ?? formacao) as Formacao & TrainingColorPayload
988
+ ) as Formacao;
989
+ const normalizedItems: LearningPathItem[] =
990
+ fullFormacao.items && fullFormacao.items.length > 0
991
+ ? fullFormacao.items.map((item, index) => ({
992
+ id: item.id,
993
+ type: item.type,
994
+ itemId: item.itemId,
995
+ order: item.order ?? index,
996
+ isRequired: item.isRequired !== false,
997
+ }))
998
+ : (fullFormacao.courseIds ?? []).map((courseId, index) => ({
999
+ type: 'course',
1000
+ itemId: courseId,
1001
+ order: index,
1002
+ isRequired: true,
1003
+ }));
1004
+
1005
+ setEditingFormacao(fullFormacao);
1006
+ initialLearningPathRef.current = [...normalizedItems];
1007
+ setLearningPathItems(normalizedItems);
1008
+ setSelectedCourseToAdd('');
1009
+ setSelectedExamToAdd('');
1010
+ form.reset({
1011
+ nome: fullFormacao.nome,
1012
+ descricao: fullFormacao.descricao,
1013
+ area: normalizeAreaValue(fullFormacao.area),
1014
+ nivel: normalizeLevelValue(fullFormacao.nivel),
1015
+ prerequisitos: fullFormacao.prerequisitos,
1016
+ status: normalizeStatusValue(fullFormacao.status),
1017
+ primaryColor: fullFormacao.primaryColor ?? '#1D4ED8',
1018
+ secondaryColor: fullFormacao.secondaryColor ?? '#111827',
1019
+ });
1020
+ setSheetOpen(true);
1021
+ } finally {
1022
+ setLoadingEditSheet(false);
1023
+ }
1024
+ }
1025
+
1026
+ function normalizeTrailOrder(items: LearningPathItem[]) {
1027
+ return items.map((item, index) => ({ ...item, order: index }));
1028
+ }
1029
+
1030
+ function addTrailItem(type: 'course' | 'exam', itemId: number) {
1031
+ setLearningPathItems((prev) => {
1032
+ if (prev.some((item) => item.type === type && item.itemId === itemId)) {
1033
+ return prev;
1034
+ }
1035
+
1036
+ return normalizeTrailOrder([
1037
+ ...prev,
1038
+ {
1039
+ type,
1040
+ itemId,
1041
+ order: prev.length,
1042
+ isRequired: true,
1043
+ },
1044
+ ]);
442
1045
  });
443
- setSheetOpen(true);
444
1046
  }
445
1047
 
446
- function toggleCurso(cursoNome: string) {
447
- setSelectedCursos((prev) =>
448
- prev.includes(cursoNome)
449
- ? prev.filter((c) => c !== cursoNome)
450
- : [...prev, cursoNome]
1048
+ function handleCourseSelection(value: string) {
1049
+ setSelectedCourseToAdd(value);
1050
+ const parsed = Number(value);
1051
+
1052
+ if (Number.isFinite(parsed) && parsed > 0) {
1053
+ addTrailItem('course', parsed);
1054
+ setSelectedCourseToAdd('');
1055
+ }
1056
+ }
1057
+
1058
+ function handleExamSelection(value: string) {
1059
+ setSelectedExamToAdd(value);
1060
+ const parsed = Number(value);
1061
+
1062
+ if (Number.isFinite(parsed) && parsed > 0) {
1063
+ addTrailItem('exam', parsed);
1064
+ setSelectedExamToAdd('');
1065
+ }
1066
+ }
1067
+
1068
+ function removeTrailItem(uid: string) {
1069
+ const [type, idText] = uid.split('-');
1070
+ const itemId = Number(idText);
1071
+
1072
+ if (!itemId || (type !== 'course' && type !== 'exam')) return;
1073
+
1074
+ setLearningPathItems((prev) =>
1075
+ normalizeTrailOrder(
1076
+ prev.filter((item) => !(item.type === type && item.itemId === itemId))
1077
+ )
451
1078
  );
452
1079
  }
453
1080
 
1081
+ function handleTrailDragEnd(event: DragEndEvent) {
1082
+ const { active, over } = event;
1083
+ if (!over || active.id === over.id) return;
1084
+
1085
+ setLearningPathItems((prev) => {
1086
+ const sorted = [...prev].sort((a, b) => a.order - b.order);
1087
+ const oldIndex = sorted.findIndex(
1088
+ (item) => `${item.type}-${item.itemId}` === String(active.id)
1089
+ );
1090
+ const newIndex = sorted.findIndex(
1091
+ (item) => `${item.type}-${item.itemId}` === String(over.id)
1092
+ );
1093
+
1094
+ if (oldIndex < 0 || newIndex < 0) return prev;
1095
+
1096
+ return normalizeTrailOrder(arrayMove(sorted, oldIndex, newIndex));
1097
+ });
1098
+ }
1099
+
1100
+ function openCreateCourseSheet() {
1101
+ courseForm.reset(DEFAULT_COURSE_FORM_VALUES);
1102
+ setCourseSheetOpen(true);
1103
+ }
1104
+
1105
+ function openCreateExamSheet() {
1106
+ examForm.reset({
1107
+ titulo: '',
1108
+ notaMinima: 7,
1109
+ limiteTempo: 60,
1110
+ shuffle: false,
1111
+ status: 'rascunho',
1112
+ });
1113
+ setExamSheetOpen(true);
1114
+ }
1115
+
1116
+ function ptStatusToApi(value: Formacao['status']) {
1117
+ if (value === 'ativa') return 'active';
1118
+ if (value === 'encerrada') return 'archived';
1119
+ return 'draft';
1120
+ }
1121
+
1122
+ function ptLevelToApi(value: string) {
1123
+ const normalized = normalizeText(value);
1124
+
1125
+ if (['iniciante', 'beginner'].includes(normalized)) return 'beginner';
1126
+ if (['intermediario', 'intermediate'].includes(normalized)) {
1127
+ return 'intermediate';
1128
+ }
1129
+ if (['avancado', 'advanced'].includes(normalized)) return 'advanced';
1130
+
1131
+ return 'beginner';
1132
+ }
1133
+
1134
+ function courseStatusToApi(value: CourseSheetFormValues['status']) {
1135
+ if (value === 'ativo') return 'published';
1136
+ if (value === 'arquivado') return 'archived';
1137
+ return 'draft';
1138
+ }
1139
+
1140
+ function examStatusToApi(value: ExamQuickForm['status']) {
1141
+ if (value === 'publicado') return 'published';
1142
+ if (value === 'encerrado') return 'closed';
1143
+ return 'draft';
1144
+ }
1145
+
1146
+ function pathsAreEqual(a: LearningPathItem[], b: LearningPathItem[]) {
1147
+ if (a.length !== b.length) return false;
1148
+
1149
+ return a.every((item, index) => {
1150
+ const other = b[index];
1151
+ if (!other) return false;
1152
+
1153
+ return (
1154
+ item.type === other.type &&
1155
+ item.itemId === other.itemId &&
1156
+ item.order === other.order
1157
+ );
1158
+ });
1159
+ }
1160
+
454
1161
  async function onSubmit(data: FormacaoForm) {
455
- setSaving(true);
456
- await new Promise((r) => setTimeout(r, 500));
457
- const cargaTotal = availableCursos
458
- .filter((c) => selectedCursos.includes(c.nome))
459
- .reduce((acc, c) => acc + c.cargaHoraria, 0);
460
- if (editingFormacao) {
461
- setFormacoes((prev) =>
462
- prev.map((f) =>
463
- f.id === editingFormacao.id
464
- ? {
465
- ...f,
466
- ...data,
467
- status: data.status as Formacao['status'],
468
- cursos: selectedCursos,
469
- cargaTotal,
470
- }
471
- : f
472
- )
1162
+ try {
1163
+ const orderedItems = normalizeTrailOrder(
1164
+ [...learningPathItems].sort((a, b) => a.order - b.order)
473
1165
  );
474
- toast.success(t('toasts.formacaoUpdated'));
475
- } else {
476
- const newFormacao: Formacao = {
477
- id: Date.now(),
478
- ...data,
479
- prerequisitos: data.prerequisitos || '',
480
- status: data.status as Formacao['status'],
481
- cursos: selectedCursos,
482
- cargaTotal,
483
- alunos: 0,
484
- criadoEm: new Date().toISOString().substring(0, 10),
485
- };
486
- setFormacoes((prev) => [newFormacao, ...prev]);
487
- toast.success(t('toasts.formacaoCriada'));
1166
+
1167
+ if (orderedItems.length === 0) {
1168
+ toast.error(t('toasts.selectAtLeastOneItem'));
1169
+ return;
1170
+ }
1171
+
1172
+ setSaving(true);
1173
+
1174
+ if (editingFormacao) {
1175
+ const dirty = form.formState.dirtyFields;
1176
+ const itemsChanged = !pathsAreEqual(
1177
+ initialLearningPathRef.current,
1178
+ orderedItems
1179
+ );
1180
+
1181
+ const payload: {
1182
+ title?: string;
1183
+ description?: string;
1184
+ shortDescription?: string;
1185
+ level?: 'beginner' | 'intermediate' | 'advanced';
1186
+ status?: 'draft' | 'active' | 'archived';
1187
+ primaryColor?: string;
1188
+ secondaryColor?: string;
1189
+ items?: Array<{
1190
+ type: 'course' | 'exam';
1191
+ itemId: number;
1192
+ order: number;
1193
+ isRequired: boolean;
1194
+ }>;
1195
+ } = {};
1196
+
1197
+ if (dirty.nome) payload.title = data.nome;
1198
+ if (dirty.descricao) payload.description = data.descricao;
1199
+ if (dirty.prerequisitos) {
1200
+ payload.shortDescription = data.prerequisitos?.trim() || undefined;
1201
+ }
1202
+ if (dirty.nivel) payload.level = ptLevelToApi(data.nivel);
1203
+ if (dirty.status) {
1204
+ payload.status = ptStatusToApi(data.status as Formacao['status']);
1205
+ }
1206
+ if (dirty.primaryColor) payload.primaryColor = data.primaryColor;
1207
+ if (dirty.secondaryColor) payload.secondaryColor = data.secondaryColor;
1208
+ if (itemsChanged) {
1209
+ payload.items = orderedItems.map((item, index) => ({
1210
+ type: item.type,
1211
+ itemId: item.itemId,
1212
+ order: index,
1213
+ isRequired: item.isRequired !== false,
1214
+ }));
1215
+ }
1216
+
1217
+ if (Object.keys(payload).length === 0) {
1218
+ setSheetOpen(false);
1219
+ return;
1220
+ }
1221
+
1222
+ await request({
1223
+ url: `/lms/training/${editingFormacao.id}`,
1224
+ method: 'PATCH',
1225
+ data: payload,
1226
+ });
1227
+ toast.success(t('toasts.formacaoUpdated'));
1228
+ } else {
1229
+ const payload = {
1230
+ title: data.nome,
1231
+ description: data.descricao,
1232
+ shortDescription: data.prerequisitos?.trim() || undefined,
1233
+ level: ptLevelToApi(data.nivel),
1234
+ status: ptStatusToApi(data.status as Formacao['status']),
1235
+ primaryColor: data.primaryColor,
1236
+ secondaryColor: data.secondaryColor,
1237
+ items: orderedItems.map((item, index) => ({
1238
+ type: item.type,
1239
+ itemId: item.itemId,
1240
+ order: index,
1241
+ isRequired: item.isRequired !== false,
1242
+ })),
1243
+ };
1244
+
1245
+ await request({
1246
+ url: '/lms/training',
1247
+ method: 'POST',
1248
+ data: payload,
1249
+ });
1250
+ toast.success(t('toasts.formacaoCriada'));
1251
+ }
1252
+
1253
+ await Promise.all([refetchTraining(), refetchStats()]);
1254
+ setSheetOpen(false);
1255
+ setLearningPathItems([]);
1256
+ initialLearningPathRef.current = [];
1257
+ } finally {
1258
+ setSaving(false);
488
1259
  }
489
- setSaving(false);
490
- setSheetOpen(false);
491
- setSelectedCursos([]);
492
1260
  }
493
1261
 
494
- function confirmDelete() {
1262
+ async function onSubmitCourse(data: CourseSheetFormValues) {
1263
+ try {
1264
+ setCreatingCourse(true);
1265
+
1266
+ const slug = slugifyText(data.nomeInterno || data.tituloComercial);
1267
+ const categorySlugs = data.categorias.map(slugifyText).filter(Boolean);
1268
+
1269
+ const response = await request<{
1270
+ id?: number;
1271
+ }>({
1272
+ url: '/lms/courses',
1273
+ method: 'POST',
1274
+ data: {
1275
+ code: buildCourseCodeFromTitle(
1276
+ data.tituloComercial || data.nomeInterno
1277
+ ),
1278
+ slug,
1279
+ title: data.tituloComercial,
1280
+ description: data.descricao,
1281
+ level: ptLevelToApi(data.nivel),
1282
+ status: courseStatusToApi(data.status),
1283
+ categorySlugs,
1284
+ },
1285
+ });
1286
+
1287
+ const createdCourseId = Number(response?.data?.id);
1288
+
1289
+ await refetchCourses();
1290
+ if (Number.isFinite(createdCourseId) && createdCourseId > 0) {
1291
+ addTrailItem('course', createdCourseId);
1292
+ setSelectedCourseToAdd('');
1293
+ }
1294
+
1295
+ setCourseSheetOpen(false);
1296
+ toast.success(t('toasts.courseCreated'));
1297
+ } finally {
1298
+ setCreatingCourse(false);
1299
+ }
1300
+ }
1301
+
1302
+ async function onSubmitExam(data: ExamQuickForm) {
1303
+ try {
1304
+ setCreatingExam(true);
1305
+
1306
+ const response = await request<{ id?: number }>({
1307
+ url: '/lms/exams',
1308
+ method: 'POST',
1309
+ data: {
1310
+ title: data.titulo,
1311
+ minScore: data.notaMinima,
1312
+ timeLimit: data.limiteTempo,
1313
+ shuffle: data.shuffle,
1314
+ status: examStatusToApi(data.status),
1315
+ },
1316
+ });
1317
+
1318
+ const createdExamId = Number(response?.data?.id);
1319
+
1320
+ await refetchExams();
1321
+ if (Number.isFinite(createdExamId) && createdExamId > 0) {
1322
+ addTrailItem('exam', createdExamId);
1323
+ setSelectedExamToAdd('');
1324
+ }
1325
+
1326
+ setExamSheetOpen(false);
1327
+ toast.success(t('toasts.examCreated'));
1328
+ } finally {
1329
+ setCreatingExam(false);
1330
+ }
1331
+ }
1332
+
1333
+ async function confirmDelete() {
495
1334
  if (!formacaoToDelete) return;
496
- setFormacoes((prev) => prev.filter((f) => f.id !== formacaoToDelete.id));
1335
+
1336
+ await request({
1337
+ url: `/lms/training/${formacaoToDelete.id}`,
1338
+ method: 'DELETE',
1339
+ });
1340
+
497
1341
  toast.success(t('toasts.formacaoRemovida'));
1342
+ await Promise.all([refetchTraining(), refetchStats()]);
498
1343
  setFormacaoToDelete(null);
499
1344
  setDeleteDialogOpen(false);
500
1345
  }
@@ -503,41 +1348,60 @@ export default function TrainingPage() {
503
1348
 
504
1349
  const kpis = [
505
1350
  {
506
- label: t('kpis.totalTraining.label'),
507
- valor: formacoes.length,
508
- sub: t('kpis.totalTraining.sub'),
1351
+ key: 'total-training',
1352
+ title: t('kpis.totalTraining.label'),
1353
+ value: statsData?.totalTraining ?? 0,
1354
+ description: t('kpis.totalTraining.sub'),
509
1355
  icon: GraduationCap,
510
- iconBg: 'bg-orange-100',
511
- iconColor: 'text-orange-600',
1356
+ layout: 'compact' as const,
1357
+ accentClassName: 'from-orange-500/25 via-amber-500/15 to-transparent',
1358
+ iconContainerClassName: 'bg-orange-100 text-orange-700',
512
1359
  },
513
1360
  {
514
- label: t('kpis.activeTraining.label'),
515
- valor: formacoes.filter((f) => f.status === 'ativa').length,
516
- sub: t('kpis.activeTraining.sub'),
1361
+ key: 'active-training',
1362
+ title: t('kpis.activeTraining.label'),
1363
+ value: statsData?.activeTraining ?? 0,
1364
+ description: t('kpis.activeTraining.sub'),
517
1365
  icon: Target,
518
- iconBg: 'bg-muted',
519
- iconColor: 'text-foreground',
1366
+ layout: 'compact' as const,
1367
+ accentClassName: 'from-sky-500/25 via-blue-500/15 to-transparent',
1368
+ iconContainerClassName: 'bg-sky-100 text-sky-700',
520
1369
  },
521
1370
  {
522
- label: t('kpis.enrolledStudents.label'),
523
- valor: formacoes
524
- .reduce((a, f) => a + f.alunos, 0)
525
- .toLocaleString('pt-BR'),
526
- sub: t('kpis.enrolledStudents.sub'),
1371
+ key: 'enrolled-students',
1372
+ title: t('kpis.enrolledStudents.label'),
1373
+ value: (statsData?.enrolledStudents ?? 0).toLocaleString('pt-BR'),
1374
+ description: t('kpis.enrolledStudents.sub'),
527
1375
  icon: Users,
528
- iconBg: 'bg-muted',
529
- iconColor: 'text-foreground',
1376
+ layout: 'compact' as const,
1377
+ accentClassName: 'from-emerald-500/25 via-green-500/15 to-transparent',
1378
+ iconContainerClassName: 'bg-emerald-100 text-emerald-700',
530
1379
  },
531
1380
  {
532
- label: t('kpis.coveredCourses.label'),
533
- valor: new Set(formacoes.flatMap((f) => f.cursos)).size,
534
- sub: t('kpis.coveredCourses.sub'),
1381
+ key: 'covered-courses',
1382
+ title: t('kpis.coveredCourses.label'),
1383
+ value: statsData?.coveredCourses ?? 0,
1384
+ description: t('kpis.coveredCourses.sub'),
535
1385
  icon: Layers,
536
- iconBg: 'bg-muted',
537
- iconColor: 'text-foreground',
1386
+ layout: 'compact' as const,
1387
+ accentClassName: 'from-pink-500/25 via-rose-500/15 to-transparent',
1388
+ iconContainerClassName: 'bg-pink-100 text-pink-700',
538
1389
  },
539
1390
  ];
540
1391
 
1392
+ const { locales } = useApp();
1393
+
1394
+ const handleNewTraining = (): void => {
1395
+ const nextLocaleData: Record<string, { name: string }> = {};
1396
+ locales.forEach((locale: Locale) => {
1397
+ nextLocaleData[locale.code] = {
1398
+ name: '',
1399
+ };
1400
+ });
1401
+ void nextLocaleData;
1402
+ openCreateSheet();
1403
+ };
1404
+
541
1405
  // ── Render ────────────────────────────────────────────────────────────────
542
1406
 
543
1407
  return (
@@ -554,298 +1418,438 @@ export default function TrainingPage() {
554
1418
  label: t('title'),
555
1419
  },
556
1420
  ]}
557
- actions={
558
- <Button onClick={openCreateSheet} className="gap-2">
559
- <Plus className="size-4" />
560
- {t('actions.createTraining')}
561
- </Button>
562
- }
1421
+ actions={[
1422
+ {
1423
+ label: t('actions.createTraining'),
1424
+ onClick: () => handleNewTraining(),
1425
+ variant: 'default',
1426
+ },
1427
+ ]}
563
1428
  />
564
1429
 
565
1430
  {/* KPIs */}
566
- <div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
567
- {loading
568
- ? Array.from({ length: 4 }).map((_, i) => (
1431
+ <div className="mb-1">
1432
+ {initialLoading && !statsData ? (
1433
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
1434
+ {Array.from({ length: 4 }).map((_, i) => (
569
1435
  <Card key={i}>
570
1436
  <CardContent className="p-4">
571
1437
  <Skeleton className="mb-2 h-8 w-16" />
572
1438
  <Skeleton className="h-4 w-28" />
573
1439
  </CardContent>
574
1440
  </Card>
575
- ))
576
- : kpis.map((kpi, i) => (
577
- <motion.div
578
- key={kpi.label}
579
- initial={{ opacity: 0, y: 12 }}
580
- animate={{ opacity: 1, y: 0 }}
581
- transition={{ delay: i * 0.07 }}
582
- >
583
- <Card className="overflow-hidden">
584
- <CardContent className="flex items-start justify-between p-5">
585
- <div>
586
- <p className="text-sm text-muted-foreground">
587
- {kpi.label}
588
- </p>
589
- <p className="mt-1 text-3xl font-bold tracking-tight">
590
- {kpi.valor}
591
- </p>
592
- <p className="mt-0.5 text-xs text-muted-foreground">
593
- {kpi.sub}
594
- </p>
595
- </div>
596
- <div
597
- className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
598
- >
599
- <kpi.icon className={`size-5 ${kpi.iconColor}`} />
600
- </div>
601
- </CardContent>
602
- </Card>
603
- </motion.div>
604
1441
  ))}
1442
+ </div>
1443
+ ) : (
1444
+ <KpiCardsGrid items={kpis} />
1445
+ )}
605
1446
  </div>
606
1447
 
607
1448
  {/* Search bar */}
608
- <form onSubmit={handleSearch} className="mb-6 mt-0">
609
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
610
- <div className="relative flex-1">
611
- <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
612
- <Input
613
- placeholder={t('filters.searchPlaceholder')}
614
- value={buscaInput}
615
- onChange={(e) => setBuscaInput(e.target.value)}
616
- className="pl-9"
1449
+ <div className="mb-1 space-y-3">
1450
+ <SearchBar
1451
+ searchQuery={buscaInput}
1452
+ onSearchChange={setBuscaInput}
1453
+ onSearch={() => {
1454
+ setBuscaDebounced(buscaInput.trim());
1455
+ setCurrentPage(1);
1456
+ }}
1457
+ placeholder={t('filters.searchPlaceholder')}
1458
+ controls={[
1459
+ {
1460
+ id: 'status',
1461
+ type: 'select',
1462
+ value: filtroStatusInput,
1463
+ onChange: setFiltroStatusInput,
1464
+ placeholder: t('filters.allStatuses'),
1465
+ options: [
1466
+ { value: 'todos', label: t('filters.allStatuses') },
1467
+ { value: 'ativa', label: t('status.active') },
1468
+ { value: 'rascunho', label: t('status.draft') },
1469
+ { value: 'encerrada', label: t('status.closed') },
1470
+ ],
1471
+ },
1472
+ {
1473
+ id: 'level',
1474
+ type: 'select',
1475
+ value: filtroNivelInput,
1476
+ onChange: setFiltroNivelInput,
1477
+ placeholder: t('filters.allLevels'),
1478
+ options: [
1479
+ { value: 'todos', label: t('filters.allLevels') },
1480
+ { value: 'Iniciante', label: t('levels.beginner') },
1481
+ { value: 'Intermediario', label: t('levels.intermediate') },
1482
+ { value: 'Avancado', label: t('levels.advanced') },
1483
+ ],
1484
+ },
1485
+ ]}
1486
+ afterSearchButton={
1487
+ <ViewModeToggle
1488
+ viewMode={viewMode}
1489
+ onViewModeChange={setViewMode}
1490
+ listLabel={t('viewMode.list')}
1491
+ cardsLabel={t('viewMode.cards')}
617
1492
  />
618
- </div>
619
- <div className="flex flex-wrap items-center gap-2">
620
- <Select value={filtroAreaInput} onValueChange={setFiltroAreaInput}>
621
- <SelectTrigger className="h-9 w-[130px] text-sm">
622
- <SelectValue placeholder={t('filters.allAreas')} />
623
- </SelectTrigger>
624
- <SelectContent>
625
- <SelectItem value="todos">{t('filters.allAreas')}</SelectItem>
626
- <SelectItem value="Tecnologia">
627
- {t('areas.technology')}
628
- </SelectItem>
629
- <SelectItem value="Design">{t('areas.design')}</SelectItem>
630
- <SelectItem value="Gestao">{t('areas.management')}</SelectItem>
631
- <SelectItem value="Marketing">
632
- {t('areas.marketing')}
633
- </SelectItem>
634
- <SelectItem value="Financas">{t('areas.finance')}</SelectItem>
635
- </SelectContent>
636
- </Select>
637
- <Select
638
- value={filtroNivelInput}
639
- onValueChange={setFiltroNivelInput}
640
- >
641
- <SelectTrigger className="h-9 w-[130px] text-sm">
642
- <SelectValue placeholder={t('filters.allLevels')} />
643
- </SelectTrigger>
644
- <SelectContent>
645
- <SelectItem value="todos">{t('filters.allLevels')}</SelectItem>
646
- <SelectItem value="Iniciante">
647
- {t('levels.beginner')}
648
- </SelectItem>
649
- <SelectItem value="Intermediario">
650
- {t('levels.intermediate')}
651
- </SelectItem>
652
- <SelectItem value="Avancado">{t('levels.advanced')}</SelectItem>
653
- </SelectContent>
654
- </Select>
1493
+ }
1494
+ />
1495
+ <div className="flex flex-wrap items-center justify-between gap-3">
1496
+ <p className="text-sm text-muted-foreground">
1497
+ {totalItems}{' '}
1498
+ {totalItems !== 1
1499
+ ? t('pagination.formacoes')
1500
+ : t('pagination.formacao')}
1501
+ </p>
1502
+ <div className="flex items-center gap-2">
655
1503
  {hasActiveFilters && (
656
1504
  <Button
657
1505
  type="button"
658
1506
  variant="ghost"
659
1507
  size="sm"
660
1508
  onClick={clearFilters}
661
- className="h-9 text-muted-foreground"
662
1509
  >
663
- <X className="mr-1 size-3.5" /> {t('filters.clear')}
1510
+ {t('filters.clear')}
664
1511
  </Button>
665
1512
  )}
666
- <Button type="submit" size="sm" className="h-9 gap-2">
667
- <Search className="size-3.5" /> {t('filters.search')}
668
- </Button>
1513
+ {cardsRefreshing && (
1514
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
1515
+ )}
669
1516
  </div>
670
1517
  </div>
671
- </form>
672
-
673
- {/* Cards grid */}
674
- {loading ? (
675
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
676
- {Array.from({ length: 6 }).map((_, i) => (
677
- <Card key={i} className="overflow-hidden">
678
- <CardContent className="p-5 min-h-[210px] flex flex-col justify-between">
679
- <div>
680
- <Skeleton className="mb-3 h-5 w-20 rounded-full" />
681
- <Skeleton className="mb-1.5 h-5 w-3/4" />
682
- <Skeleton className="mb-4 h-4 w-full" />
683
- </div>
684
- <div className="flex gap-2 mt-auto">
685
- <Skeleton className="h-6 w-16 rounded-full" />
686
- <Skeleton className="h-6 w-20 rounded-full" />
687
- </div>
688
- </CardContent>
689
- </Card>
690
- ))}
691
- </div>
692
- ) : filteredFormacoes.length === 0 ? (
1518
+ </div>
1519
+
1520
+ {/* Training list */}
1521
+ {initialLoading ? (
1522
+ viewMode === 'cards' ? (
1523
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
1524
+ {Array.from({ length: 6 }).map((_, i) => (
1525
+ <Card key={i} className="overflow-hidden">
1526
+ <CardContent className="p-5">
1527
+ <div className="flex min-h-52.5 flex-col justify-between">
1528
+ <div>
1529
+ <Skeleton className="mb-3 h-5 w-20 rounded-full" />
1530
+ <Skeleton className="mb-1.5 h-5 w-3/4" />
1531
+ <Skeleton className="mb-4 h-4 w-full" />
1532
+ </div>
1533
+ <div className="mt-auto flex gap-2">
1534
+ <Skeleton className="h-6 w-16 rounded-full" />
1535
+ <Skeleton className="h-6 w-20 rounded-full" />
1536
+ </div>
1537
+ </div>
1538
+ </CardContent>
1539
+ </Card>
1540
+ ))}
1541
+ </div>
1542
+ ) : (
1543
+ <div className="overflow-hidden rounded-xl border border-border/70">
1544
+ <Table>
1545
+ <TableHeader>
1546
+ <TableRow>
1547
+ <TableHead>{t('form.fields.nome.label')}</TableHead>
1548
+ <TableHead>{t('form.fields.level.label')}</TableHead>
1549
+ <TableHead>{t('form.fields.status.label')}</TableHead>
1550
+ <TableHead>{t('cards.coursesLabel')}</TableHead>
1551
+ <TableHead>{t('cards.hoursLabel')}</TableHead>
1552
+ <TableHead className="text-right">
1553
+ {t('cards.studentsLabel')}
1554
+ </TableHead>
1555
+ <TableHead className="w-12" />
1556
+ </TableRow>
1557
+ </TableHeader>
1558
+ <TableBody>
1559
+ {Array.from({ length: 6 }).map((_, i) => (
1560
+ <TableRow key={i}>
1561
+ <TableCell>
1562
+ <div className="space-y-1.5">
1563
+ <Skeleton className="h-4 w-44" />
1564
+ <Skeleton className="h-3 w-56" />
1565
+ </div>
1566
+ </TableCell>
1567
+ <TableCell>
1568
+ <Skeleton className="h-5 w-20 rounded-full" />
1569
+ </TableCell>
1570
+ <TableCell>
1571
+ <Skeleton className="h-5 w-20 rounded-full" />
1572
+ </TableCell>
1573
+ <TableCell>
1574
+ <Skeleton className="h-4 w-12" />
1575
+ </TableCell>
1576
+ <TableCell>
1577
+ <Skeleton className="h-4 w-10" />
1578
+ </TableCell>
1579
+ <TableCell className="text-right">
1580
+ <Skeleton className="ml-auto h-4 w-12" />
1581
+ </TableCell>
1582
+ <TableCell>
1583
+ <Skeleton className="ml-auto size-8 rounded-md" />
1584
+ </TableCell>
1585
+ </TableRow>
1586
+ ))}
1587
+ </TableBody>
1588
+ </Table>
1589
+ </div>
1590
+ )
1591
+ ) : totalItems === 0 ? (
693
1592
  <EmptyState
694
- icon={<GraduationCap className="h-12 w-12" />}
1593
+ icon={<GraduationCap className="size-12 text-muted-foreground/40" />}
695
1594
  title={t('empty.title')}
696
1595
  description={t('empty.description')}
697
1596
  actionLabel={t('empty.action')}
1597
+ actionIcon={<Plus className="mr-2 size-4" />}
698
1598
  onAction={openCreateSheet}
699
- actionIcon={<Plus className="mr-2 h-4 w-4" />}
1599
+ className="py-20"
700
1600
  />
701
1601
  ) : (
702
- <motion.div
703
- className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
704
- variants={stagger}
705
- initial="hidden"
706
- animate="show"
707
- >
708
- {paginatedFormacoes.map((formacao) => {
709
- const statusCfg = STATUS_MAP[formacao.status] ?? {
710
- label: formacao.status,
711
- variant: 'default' as const,
712
- };
713
- const areaColor =
714
- AREA_COLORS[formacao.area] ??
715
- 'bg-muted text-foreground border-border';
716
-
717
- return (
718
- <motion.div key={formacao.id} variants={fadeUp}>
719
- <Card
720
- className="group relative flex min-h-60 max-h-[270px] cursor-pointer flex-col overflow-hidden transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
721
- onClick={() => handleCardClick(formacao)}
722
- title={t('cards.tooltip')}
723
- >
724
- <CardContent className="p-5 flex flex-col h-full">
725
- {/* Top */}
726
- <div className="mb-3 flex items-center justify-between gap-2">
727
- <div className="flex flex-wrap items-center gap-1.5">
728
- <span
729
- className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${areaColor}`}
730
- >
731
- {formacao.area}
732
- </span>
733
- <Badge variant={statusCfg.variant} className="text-xs">
734
- {statusCfg.label}
735
- </Badge>
736
- </div>
737
- <DropdownMenu>
738
- <DropdownMenuTrigger asChild>
739
- <Button
740
- variant="ghost"
741
- size="icon"
742
- className="size-8 shrink-0 -mr-2 -mt-1"
743
- onClick={(e) => e.stopPropagation()}
744
- aria-label={t('cards.actions.label')}
745
- >
746
- <MoreHorizontal className="size-4" />
747
- </Button>
748
- </DropdownMenuTrigger>
749
- <DropdownMenuContent align="end" className="w-48">
750
- <DropdownMenuItem
751
- onClick={(e) => {
752
- e.stopPropagation();
753
- toast.info(t('toasts.openingDetails'));
754
- }}
755
- >
756
- <Eye className="mr-2 size-4" />{' '}
757
- {t('cards.actions.viewDetails')}
758
- </DropdownMenuItem>
759
- <DropdownMenuItem
760
- onClick={(e) => openEditSheet(formacao, e)}
761
- >
762
- <Pencil className="mr-2 size-4" />{' '}
763
- {t('cards.actions.edit')}
764
- </DropdownMenuItem>
765
- <DropdownMenuSeparator />
766
- <DropdownMenuItem
767
- className="text-destructive focus:text-destructive"
768
- onClick={(e) => {
769
- e.stopPropagation();
770
- setFormacaoToDelete(formacao);
771
- setDeleteDialogOpen(true);
772
- }}
773
- >
774
- <Trash2 className="mr-2 size-4" />{' '}
775
- {t('cards.actions.delete')}
776
- </DropdownMenuItem>
777
- </DropdownMenuContent>
778
- </DropdownMenu>
779
- </div>
1602
+ <div className="relative">
1603
+ {cardsRefreshing && (
1604
+ <div className="absolute inset-0 z-10 rounded-2xl bg-background/55 backdrop-blur-[1px]" />
1605
+ )}
1606
+ {viewMode === 'cards' ? (
1607
+ <motion.div
1608
+ className={`${'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'} ${cardsRefreshing ? 'pointer-events-none' : ''}`}
1609
+ variants={stagger}
1610
+ initial="hidden"
1611
+ animate="show"
1612
+ >
1613
+ {formacoesToRender.map((formacao) => {
1614
+ const statusCfg = STATUS_MAP[formacao.status] ?? {
1615
+ label: formacao.status,
1616
+ variant: 'default' as const,
1617
+ };
1618
+ const cardTopColor =
1619
+ normalizeHexColor(formacao.primaryColor) ?? '#1D4ED8';
780
1620
 
781
- {/* Title */}
782
- <h3 className="mb-0.5 font-semibold leading-tight">
783
- {formacao.nome}
784
- </h3>
785
- <p className="mb-4 line-clamp-2 text-sm text-muted-foreground leading-relaxed">
786
- {formacao.descricao}
787
- </p>
1621
+ return (
1622
+ <motion.div key={formacao.id} variants={fadeUp}>
1623
+ <Card
1624
+ className="group relative flex min-h-60 max-h-67.5 cursor-pointer flex-col overflow-hidden border-border/70 py-0 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md"
1625
+ onClick={() => handleCardClick(formacao)}
1626
+ title={t('cards.tooltip')}
1627
+ >
1628
+ <div
1629
+ className="absolute inset-x-0 top-0 h-1"
1630
+ style={{
1631
+ backgroundColor: cardTopColor,
1632
+ }}
1633
+ />
1634
+ <CardContent className="flex h-full flex-col p-4 pt-5">
1635
+ <div className="mb-3 flex items-center justify-between gap-2">
1636
+ <div className="flex flex-wrap items-center gap-1.5">
1637
+ <Badge variant="outline" className="text-xs">
1638
+ {normalizeLevelValue(formacao.nivel)}
1639
+ </Badge>
1640
+ <Badge
1641
+ variant={statusCfg.variant}
1642
+ className="text-xs"
1643
+ >
1644
+ {statusCfg.label}
1645
+ </Badge>
1646
+ </div>
1647
+ <DropdownMenu>
1648
+ <DropdownMenuTrigger asChild>
1649
+ <Button
1650
+ variant="ghost"
1651
+ size="icon"
1652
+ className="size-8 shrink-0 -mr-2 -mt-1"
1653
+ onClick={(e) => e.stopPropagation()}
1654
+ aria-label={t('cards.actions.label')}
1655
+ >
1656
+ <MoreHorizontal className="size-4" />
1657
+ </Button>
1658
+ </DropdownMenuTrigger>
1659
+ <DropdownMenuContent align="end" className="w-48">
1660
+ <DropdownMenuItem
1661
+ onClick={(e) => openEditSheet(formacao, e)}
1662
+ >
1663
+ <Pencil className="mr-2 size-4" />{' '}
1664
+ {t('cards.actions.edit')}
1665
+ </DropdownMenuItem>
1666
+ <DropdownMenuSeparator />
1667
+ <DropdownMenuItem
1668
+ className="text-destructive focus:text-destructive"
1669
+ onClick={(e) => openDeleteDialog(formacao, e)}
1670
+ >
1671
+ <Trash2 className="mr-2 size-4" />{' '}
1672
+ {t('cards.actions.delete')}
1673
+ </DropdownMenuItem>
1674
+ </DropdownMenuContent>
1675
+ </DropdownMenu>
1676
+ </div>
788
1677
 
789
- {/* Course tags */}
790
- <div className="mb-4 flex flex-wrap gap-1">
791
- {formacao.cursos.slice(0, 3).map((c) => (
792
- <Badge
793
- key={c}
794
- variant="outline"
795
- className="text-xs px-1.5 py-0"
796
- >
797
- {c}
798
- </Badge>
799
- ))}
800
- {formacao.cursos.length > 3 && (
801
- <Badge
802
- variant="outline"
803
- className="text-xs px-1.5 py-0"
804
- >
805
- +{formacao.cursos.length - 3}
806
- </Badge>
807
- )}
808
- </div>
1678
+ <h3 className="mb-0.5 font-semibold leading-tight">
1679
+ {formacao.nome}
1680
+ </h3>
1681
+ <p className="mb-4 line-clamp-2 text-sm text-muted-foreground leading-relaxed">
1682
+ {formacao.descricao}
1683
+ </p>
1684
+
1685
+ <p className="mb-3 line-clamp-1 text-xs text-muted-foreground">
1686
+ {t('cards.prerequisiteLabel')}{' '}
1687
+ {formacao.prerequisitos?.trim()
1688
+ ? formacao.prerequisitos
1689
+ : t('cards.noPrerequisite')}
1690
+ </p>
1691
+
1692
+ <div className="mb-4 flex flex-wrap gap-1">
1693
+ {formacao.cursos.slice(0, 3).map((c) => (
1694
+ <Badge
1695
+ key={c}
1696
+ variant="outline"
1697
+ className="text-xs px-1.5 py-0"
1698
+ >
1699
+ {c}
1700
+ </Badge>
1701
+ ))}
1702
+ {formacao.cursos.length > 3 && (
1703
+ <Badge
1704
+ variant="outline"
1705
+ className="text-xs px-1.5 py-0"
1706
+ >
1707
+ +{formacao.cursos.length - 3}
1708
+ </Badge>
1709
+ )}
1710
+ </div>
809
1711
 
810
- <Separator className="mb-3" />
811
-
812
- {/* Footer */}
813
- <div className="flex items-center justify-between text-xs text-muted-foreground">
814
- <div className="flex items-center gap-3">
815
- <span className="flex items-center gap-1">
816
- <Layers className="size-3.5" />
817
- {formacao.cursos.length} {t('cards.coursesLabel')}
818
- </span>
819
- <span className="flex items-center gap-1">
820
- <Clock className="size-3.5" />
1712
+ <Separator className="mb-3" />
1713
+
1714
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
1715
+ <div className="flex items-center gap-3">
1716
+ <span className="flex items-center gap-1">
1717
+ <Layers className="size-3.5" />
1718
+ {formacao.cursos.length} {t('cards.coursesLabel')}
1719
+ </span>
1720
+ <span className="flex items-center gap-1">
1721
+ <Clock className="size-3.5" />
1722
+ {formacao.cargaTotal}
1723
+ {t('cards.hoursLabel')}
1724
+ </span>
1725
+ </div>
1726
+ <span className="flex items-center gap-1">
1727
+ <Users className="size-3.5" />
1728
+ {formacao.alunos.toLocaleString('pt-BR')}{' '}
1729
+ {t('cards.studentsLabel')}
1730
+ </span>
1731
+ </div>
1732
+ </CardContent>
1733
+ </Card>
1734
+ </motion.div>
1735
+ );
1736
+ })}
1737
+ </motion.div>
1738
+ ) : (
1739
+ <div
1740
+ className={`overflow-hidden rounded-xl border border-border/70 ${cardsRefreshing ? 'pointer-events-none' : ''}`}
1741
+ >
1742
+ <Table>
1743
+ <TableHeader>
1744
+ <TableRow>
1745
+ <TableHead>{t('form.fields.nome.label')}</TableHead>
1746
+ <TableHead>{t('form.fields.level.label')}</TableHead>
1747
+ <TableHead>{t('form.fields.status.label')}</TableHead>
1748
+ <TableHead>{t('cards.coursesLabel')}</TableHead>
1749
+ <TableHead>{t('cards.hoursLabel')}</TableHead>
1750
+ <TableHead className="text-right">
1751
+ {t('cards.studentsLabel')}
1752
+ </TableHead>
1753
+ <TableHead className="w-12" />
1754
+ </TableRow>
1755
+ </TableHeader>
1756
+ <TableBody>
1757
+ {formacoesToRender.map((formacao) => {
1758
+ const statusCfg = STATUS_MAP[formacao.status] ?? {
1759
+ label: formacao.status,
1760
+ variant: 'default' as const,
1761
+ };
1762
+
1763
+ return (
1764
+ <TableRow
1765
+ key={formacao.id}
1766
+ className="cursor-pointer"
1767
+ onClick={() => handleCardClick(formacao)}
1768
+ title={t('cards.tooltip')}
1769
+ >
1770
+ <TableCell>
1771
+ <div className="min-w-0">
1772
+ <p className="truncate font-semibold text-foreground">
1773
+ {formacao.nome}
1774
+ </p>
1775
+ <p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
1776
+ {formacao.prerequisitos?.trim()
1777
+ ? formacao.prerequisitos
1778
+ : t('cards.noPrerequisite')}
1779
+ </p>
1780
+ </div>
1781
+ </TableCell>
1782
+ <TableCell>
1783
+ <Badge variant="outline" className="text-xs">
1784
+ {normalizeLevelValue(formacao.nivel)}
1785
+ </Badge>
1786
+ </TableCell>
1787
+ <TableCell>
1788
+ <Badge
1789
+ variant={statusCfg.variant}
1790
+ className="text-xs"
1791
+ >
1792
+ {statusCfg.label}
1793
+ </Badge>
1794
+ </TableCell>
1795
+ <TableCell>{formacao.cursos.length}</TableCell>
1796
+ <TableCell>
821
1797
  {formacao.cargaTotal}
822
1798
  {t('cards.hoursLabel')}
823
- </span>
824
- </div>
825
- <span className="flex items-center gap-1">
826
- <Users className="size-3.5" />
827
- {formacao.alunos.toLocaleString('pt-BR')}{' '}
828
- {t('cards.studentsLabel')}
829
- </span>
830
- </div>
831
- </CardContent>
832
- </Card>
833
- </motion.div>
834
- );
835
- })}
836
- </motion.div>
1799
+ </TableCell>
1800
+ <TableCell className="text-right font-medium">
1801
+ {formacao.alunos.toLocaleString('pt-BR')}
1802
+ </TableCell>
1803
+ <TableCell onClick={(e) => e.stopPropagation()}>
1804
+ <DropdownMenu>
1805
+ <DropdownMenuTrigger asChild>
1806
+ <Button
1807
+ variant="ghost"
1808
+ size="icon"
1809
+ className="ml-auto size-8"
1810
+ aria-label={t('cards.actions.label')}
1811
+ >
1812
+ <MoreHorizontal className="size-4" />
1813
+ </Button>
1814
+ </DropdownMenuTrigger>
1815
+ <DropdownMenuContent align="end" className="w-48">
1816
+ <DropdownMenuItem
1817
+ onClick={() => openEditSheet(formacao)}
1818
+ >
1819
+ <Pencil className="mr-2 size-4" />{' '}
1820
+ {t('cards.actions.edit')}
1821
+ </DropdownMenuItem>
1822
+ <DropdownMenuSeparator />
1823
+ <DropdownMenuItem
1824
+ className="text-destructive focus:text-destructive"
1825
+ onClick={(e) => openDeleteDialog(formacao, e)}
1826
+ >
1827
+ <Trash2 className="mr-2 size-4" />{' '}
1828
+ {t('cards.actions.delete')}
1829
+ </DropdownMenuItem>
1830
+ </DropdownMenuContent>
1831
+ </DropdownMenu>
1832
+ </TableCell>
1833
+ </TableRow>
1834
+ );
1835
+ })}
1836
+ </TableBody>
1837
+ </Table>
1838
+ </div>
1839
+ )}
1840
+ </div>
837
1841
  )}
838
1842
 
839
1843
  {/* Pagination footer */}
840
- {!loading && filteredFormacoes.length > 0 && (
1844
+ {!initialLoading && totalItems > 0 && (
841
1845
  <div className="mt-6">
842
1846
  <PaginationFooter
843
1847
  currentPage={safePage}
844
1848
  pageSize={pageSize}
845
- totalItems={filteredFormacoes.length}
1849
+ totalItems={totalItems}
846
1850
  onPageChange={setCurrentPage}
847
- onPageSizeChange={(nextSize) => {
848
- setPageSize(nextSize);
1851
+ onPageSizeChange={(nextPageSize) => {
1852
+ setPageSize(nextPageSize);
849
1853
  setCurrentPage(1);
850
1854
  }}
851
1855
  pageSizeOptions={PAGE_SIZES}
@@ -896,105 +1900,70 @@ export default function TrainingPage() {
896
1900
  {form.formState.errors.descricao?.message}
897
1901
  </FieldError>
898
1902
  </Field>
899
- <div className="grid grid-cols-2 gap-4">
1903
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
900
1904
  <Field>
901
1905
  <FieldLabel>
902
- {t('form.fields.area.label')}{' '}
1906
+ {t('form.fields.nivel.label')}{' '}
903
1907
  <span className="text-destructive">*</span>
904
1908
  </FieldLabel>
905
1909
  <Controller
906
- name="area"
1910
+ name="nivel"
907
1911
  control={form.control}
908
1912
  render={({ field }) => (
909
1913
  <Select onValueChange={field.onChange} value={field.value}>
910
1914
  <SelectTrigger>
911
1915
  <SelectValue
912
- placeholder={t('form.fields.area.placeholder')}
1916
+ placeholder={t('form.fields.nivel.placeholder')}
913
1917
  />
914
1918
  </SelectTrigger>
915
1919
  <SelectContent>
916
- <SelectItem value="Tecnologia">
917
- {t('areas.technology')}
918
- </SelectItem>
919
- <SelectItem value="Design">
920
- {t('areas.design')}
921
- </SelectItem>
922
- <SelectItem value="Gestao">
923
- {t('areas.management')}
1920
+ <SelectItem value="Iniciante">
1921
+ {t('levels.beginner')}
924
1922
  </SelectItem>
925
- <SelectItem value="Marketing">
926
- {t('areas.marketing')}
1923
+ <SelectItem value="Intermediario">
1924
+ {t('levels.intermediate')}
927
1925
  </SelectItem>
928
- <SelectItem value="Financas">
929
- {t('areas.finance')}
1926
+ <SelectItem value="Avancado">
1927
+ {t('levels.advanced')}
930
1928
  </SelectItem>
931
1929
  </SelectContent>
932
1930
  </Select>
933
1931
  )}
934
1932
  />
935
- <FieldError>{form.formState.errors.area?.message}</FieldError>
1933
+ <FieldError>{form.formState.errors.nivel?.message}</FieldError>
936
1934
  </Field>
1935
+
937
1936
  <Field>
938
1937
  <FieldLabel>
939
- {t('form.fields.nivel.label')}{' '}
1938
+ {t('form.fields.status.label')}{' '}
940
1939
  <span className="text-destructive">*</span>
941
1940
  </FieldLabel>
942
1941
  <Controller
943
- name="nivel"
1942
+ name="status"
944
1943
  control={form.control}
945
1944
  render={({ field }) => (
946
1945
  <Select onValueChange={field.onChange} value={field.value}>
947
1946
  <SelectTrigger>
948
- <SelectValue
949
- placeholder={t('form.fields.nivel.placeholder')}
950
- />
1947
+ <SelectValue />
951
1948
  </SelectTrigger>
952
1949
  <SelectContent>
953
- <SelectItem value="Iniciante">
954
- {t('levels.beginner')}
1950
+ <SelectItem value="rascunho">
1951
+ {t('status.draft')}
955
1952
  </SelectItem>
956
- <SelectItem value="Intermediario">
957
- {t('levels.intermediate')}
1953
+ <SelectItem value="ativa">
1954
+ {t('status.active')}
958
1955
  </SelectItem>
959
- <SelectItem value="Avancado">
960
- {t('levels.advanced')}
1956
+ <SelectItem value="encerrada">
1957
+ {t('status.closed')}
961
1958
  </SelectItem>
962
1959
  </SelectContent>
963
1960
  </Select>
964
1961
  )}
965
1962
  />
966
- <FieldError>{form.formState.errors.nivel?.message}</FieldError>
1963
+ <FieldError>{form.formState.errors.status?.message}</FieldError>
967
1964
  </Field>
968
1965
  </div>
969
- <Field>
970
- <FieldLabel>
971
- {t('form.fields.status.label')}{' '}
972
- <span className="text-destructive">*</span>
973
- </FieldLabel>
974
- <Controller
975
- name="status"
976
- control={form.control}
977
- render={({ field }) => (
978
- <Select onValueChange={field.onChange} value={field.value}>
979
- <SelectTrigger>
980
- <SelectValue />
981
- </SelectTrigger>
982
- <SelectContent>
983
- <SelectItem value="rascunho">
984
- {t('status.draft')}
985
- </SelectItem>
986
- <SelectItem value="ativa">
987
- {t('status.active')}
988
- </SelectItem>
989
- <SelectItem value="encerrada">
990
- {t('status.closed')}
991
- </SelectItem>
992
- </SelectContent>
993
- </Select>
994
- )}
995
- />
996
- <FieldError>{form.formState.errors.status?.message}</FieldError>
997
- </Field>
1966
+
998
1967
  <Field>
999
1968
  <FieldLabel htmlFor="prerequisitos">
1000
1969
  {t('form.fields.prerequisitos.label')}
@@ -1006,34 +1975,175 @@ export default function TrainingPage() {
1006
1975
  />
1007
1976
  </Field>
1008
1977
 
1009
- {/* Cursos */}
1010
- <Field>
1011
- <FieldLabel>{t('form.fields.cursos.label')}</FieldLabel>
1012
- <div className="rounded-md border">
1013
- {availableCursos.map((c) => (
1014
- <label
1015
- key={c.id}
1016
- className="flex cursor-pointer items-center justify-between border-b p-2.5 last:border-0 hover:bg-muted has-checked:bg-muted/50"
1017
- >
1978
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1979
+ <Field>
1980
+ <FieldLabel htmlFor="primaryColor">Cor Primária</FieldLabel>
1981
+ <Controller
1982
+ name="primaryColor"
1983
+ control={form.control}
1984
+ render={({ field }) => (
1018
1985
  <div className="flex items-center gap-2">
1019
- <Checkbox
1020
- checked={selectedCursos.includes(c.nome)}
1021
- onCheckedChange={() => toggleCurso(c.nome)}
1986
+ <Input
1987
+ id="primaryColor"
1988
+ type="color"
1989
+ className="h-10 w-16 p-1"
1990
+ value={field.value}
1991
+ onChange={field.onChange}
1992
+ />
1993
+ <Input
1994
+ value={field.value}
1995
+ onChange={field.onChange}
1996
+ placeholder="#1D4ED8"
1022
1997
  />
1023
- <span className="text-sm">{c.nome}</span>
1024
1998
  </div>
1025
- <span className="text-xs text-muted-foreground">
1026
- {c.cargaHoraria}h
1027
- </span>
1028
- </label>
1029
- ))}
1999
+ )}
2000
+ />
2001
+ <FieldError>
2002
+ {form.formState.errors.primaryColor?.message}
2003
+ </FieldError>
2004
+ </Field>
2005
+
2006
+ <Field>
2007
+ <FieldLabel htmlFor="secondaryColor">Cor Secundária</FieldLabel>
2008
+ <Controller
2009
+ name="secondaryColor"
2010
+ control={form.control}
2011
+ render={({ field }) => (
2012
+ <div className="flex items-center gap-2">
2013
+ <Input
2014
+ id="secondaryColor"
2015
+ type="color"
2016
+ className="h-10 w-16 p-1"
2017
+ value={field.value}
2018
+ onChange={field.onChange}
2019
+ />
2020
+ <Input
2021
+ value={field.value}
2022
+ onChange={field.onChange}
2023
+ placeholder="#111827"
2024
+ />
2025
+ </div>
2026
+ )}
2027
+ />
2028
+ <FieldError>
2029
+ {form.formState.errors.secondaryColor?.message}
2030
+ </FieldError>
2031
+ </Field>
2032
+ </div>
2033
+
2034
+ {/* Trilha */}
2035
+ <Field>
2036
+ <FieldLabel>{t('form.fields.trilha.label')}</FieldLabel>
2037
+ <div className="space-y-2">
2038
+ <div className="rounded-md border bg-muted/20 p-3">
2039
+ <div className="space-y-2">
2040
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2041
+ <Select
2042
+ value={selectedCourseToAdd}
2043
+ onValueChange={handleCourseSelection}
2044
+ disabled={selectableCursos.length === 0}
2045
+ >
2046
+ <SelectTrigger className="w-full">
2047
+ <SelectValue
2048
+ placeholder={t('form.fields.cursos.placeholder')}
2049
+ />
2050
+ </SelectTrigger>
2051
+ <SelectContent>
2052
+ {selectableCursos.map((course) => (
2053
+ <SelectItem
2054
+ key={course.id}
2055
+ value={String(course.id)}
2056
+ >
2057
+ {course.nome} ({course.cargaHoraria}h)
2058
+ </SelectItem>
2059
+ ))}
2060
+ </SelectContent>
2061
+ </Select>
2062
+ <Button
2063
+ type="button"
2064
+ variant="outline"
2065
+ className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2066
+ onClick={openCreateCourseSheet}
2067
+ >
2068
+ <Plus className="size-4" />
2069
+ </Button>
2070
+ </div>
2071
+
2072
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2073
+ <Select
2074
+ value={selectedExamToAdd}
2075
+ onValueChange={handleExamSelection}
2076
+ disabled={selectableExams.length === 0}
2077
+ >
2078
+ <SelectTrigger className="w-full">
2079
+ <SelectValue
2080
+ placeholder={t('form.fields.exames.placeholder')}
2081
+ />
2082
+ </SelectTrigger>
2083
+ <SelectContent>
2084
+ {selectableExams.map((exam) => (
2085
+ <SelectItem key={exam.id} value={String(exam.id)}>
2086
+ {exam.titulo} ({exam.limiteTempo}min)
2087
+ </SelectItem>
2088
+ ))}
2089
+ </SelectContent>
2090
+ </Select>
2091
+ <Button
2092
+ type="button"
2093
+ variant="outline"
2094
+ className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2095
+ onClick={openCreateExamSheet}
2096
+ >
2097
+ <Plus className="size-4" />
2098
+ </Button>
2099
+ </div>
2100
+ </div>
2101
+ </div>
2102
+
2103
+ {trailItems.length > 0 ? (
2104
+ <>
2105
+ <div className="rounded-md border bg-background">
2106
+ <DndContext
2107
+ sensors={sensors}
2108
+ collisionDetection={closestCenter}
2109
+ onDragEnd={handleTrailDragEnd}
2110
+ >
2111
+ <SortableContext
2112
+ items={trailItems.map((item) => item.uid)}
2113
+ strategy={verticalListSortingStrategy}
2114
+ >
2115
+ {trailItems.map((item) => (
2116
+ <SortableTrailItem
2117
+ key={item.uid}
2118
+ item={item}
2119
+ onRemove={removeTrailItem}
2120
+ />
2121
+ ))}
2122
+ </SortableContext>
2123
+ </DndContext>
2124
+ </div>
2125
+ </>
2126
+ ) : (
2127
+ <div className="rounded-md border p-3">
2128
+ <p className="text-sm text-muted-foreground">
2129
+ {t('form.fields.trilha.empty')}
2130
+ </p>
2131
+ </div>
2132
+ )}
1030
2133
  </div>
1031
- {selectedCursos.length > 0 && (
2134
+
2135
+ {(isFetchingCourses || isFetchingExams) && (
2136
+ <p className="text-xs text-muted-foreground">
2137
+ {t('form.fields.trilha.loading')}
2138
+ </p>
2139
+ )}
2140
+
2141
+ {learningPathItems.length > 0 && (
1032
2142
  <p className="text-xs text-muted-foreground mt-1">
1033
- {selectedCursos.length} {t('coursesSummary.courses')}{' '}
2143
+ {learningPathItems.length} {t('coursesSummary.items')}{' '}
1034
2144
  {t('coursesSummary.dot')}{' '}
1035
2145
  {availableCursos
1036
- .filter((c) => selectedCursos.includes(c.nome))
2146
+ .filter((c) => selectedCursos.includes(c.id))
1037
2147
  .reduce((a, c) => a + c.cargaHoraria, 0)}
1038
2148
  {t('coursesSummary.hours')}
1039
2149
  </p>
@@ -1041,8 +2151,14 @@ export default function TrainingPage() {
1041
2151
  </Field>
1042
2152
 
1043
2153
  <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">
1044
- <Button type="submit" disabled={saving} className="gap-2">
1045
- {saving && <Loader2 className="size-4 animate-spin" />}
2154
+ <Button
2155
+ type="submit"
2156
+ disabled={saving || loadingEditSheet}
2157
+ className="gap-2"
2158
+ >
2159
+ {(saving || loadingEditSheet) && (
2160
+ <Loader2 className="size-4 animate-spin" />
2161
+ )}
1046
2162
  {editingFormacao
1047
2163
  ? t('form.actions.save')
1048
2164
  : t('form.actions.create')}
@@ -1052,9 +2168,138 @@ export default function TrainingPage() {
1052
2168
  </SheetContent>
1053
2169
  </Sheet>
1054
2170
 
2171
+ <CourseFormSheet
2172
+ open={courseSheetOpen}
2173
+ onOpenChange={setCourseSheetOpen}
2174
+ editing={false}
2175
+ saving={creatingCourse}
2176
+ form={courseForm}
2177
+ onSubmit={onSubmitCourse}
2178
+ categories={categoryOptions}
2179
+ onCreateCategory={() => router.push('/category?new=1')}
2180
+ t={tCourse}
2181
+ />
2182
+
2183
+ <Sheet open={examSheetOpen} onOpenChange={setExamSheetOpen}>
2184
+ <SheetContent
2185
+ side="right"
2186
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
2187
+ >
2188
+ <SheetHeader>
2189
+ <SheetTitle>{t('examForm.title')}</SheetTitle>
2190
+ <SheetDescription>{t('examForm.description')}</SheetDescription>
2191
+ </SheetHeader>
2192
+
2193
+ <form
2194
+ onSubmit={examForm.handleSubmit(onSubmitExam)}
2195
+ className="flex flex-1 flex-col gap-4 px-4 py-6"
2196
+ >
2197
+ <Field>
2198
+ <FieldLabel htmlFor="exam-titulo">
2199
+ {tExam('form.fields.title.label')}{' '}
2200
+ <span className="text-destructive">*</span>
2201
+ </FieldLabel>
2202
+ <Input
2203
+ id="exam-titulo"
2204
+ placeholder={tExam('form.fields.title.placeholder')}
2205
+ {...examForm.register('titulo')}
2206
+ />
2207
+ <FieldError>
2208
+ {examForm.formState.errors.titulo?.message}
2209
+ </FieldError>
2210
+ </Field>
2211
+
2212
+ <div className="grid grid-cols-2 gap-4">
2213
+ <Field>
2214
+ <FieldLabel htmlFor="exam-min-score">
2215
+ {tExam('form.fields.minScore.label')}{' '}
2216
+ <span className="text-destructive">*</span>
2217
+ </FieldLabel>
2218
+ <Input
2219
+ id="exam-min-score"
2220
+ type="number"
2221
+ step="0.5"
2222
+ {...examForm.register('notaMinima')}
2223
+ />
2224
+ </Field>
2225
+
2226
+ <Field>
2227
+ <FieldLabel htmlFor="exam-time-limit">
2228
+ {tExam('form.fields.timeLimit.label')}{' '}
2229
+ <span className="text-destructive">*</span>
2230
+ </FieldLabel>
2231
+ <Input
2232
+ id="exam-time-limit"
2233
+ type="number"
2234
+ {...examForm.register('limiteTempo')}
2235
+ />
2236
+ </Field>
2237
+ </div>
2238
+
2239
+ <Field>
2240
+ <FieldLabel>{tExam('form.fields.status.label')}</FieldLabel>
2241
+ <Controller
2242
+ name="status"
2243
+ control={examForm.control}
2244
+ render={({ field }) => (
2245
+ <Select onValueChange={field.onChange} value={field.value}>
2246
+ <SelectTrigger>
2247
+ <SelectValue
2248
+ placeholder={tExam('form.fields.status.placeholder')}
2249
+ />
2250
+ </SelectTrigger>
2251
+ <SelectContent>
2252
+ <SelectItem value="rascunho">
2253
+ {tExam('status.draft')}
2254
+ </SelectItem>
2255
+ <SelectItem value="publicado">
2256
+ {tExam('status.published')}
2257
+ </SelectItem>
2258
+ <SelectItem value="encerrado">
2259
+ {tExam('status.closed')}
2260
+ </SelectItem>
2261
+ </SelectContent>
2262
+ </Select>
2263
+ )}
2264
+ />
2265
+ </Field>
2266
+
2267
+ <Field>
2268
+ <div className="flex items-center justify-between rounded-lg border p-3">
2269
+ <div>
2270
+ <p className="text-sm font-medium">
2271
+ {tExam('form.fields.shuffle.label')}
2272
+ </p>
2273
+ <p className="text-xs text-muted-foreground">
2274
+ {tExam('form.fields.shuffle.description')}
2275
+ </p>
2276
+ </div>
2277
+ <Controller
2278
+ name="shuffle"
2279
+ control={examForm.control}
2280
+ render={({ field }) => (
2281
+ <Switch
2282
+ checked={field.value}
2283
+ onCheckedChange={field.onChange}
2284
+ />
2285
+ )}
2286
+ />
2287
+ </div>
2288
+ </Field>
2289
+
2290
+ <SheetFooter className="mt-auto px-0">
2291
+ <Button type="submit" disabled={creatingExam} className="gap-2">
2292
+ {creatingExam && <Loader2 className="size-4 animate-spin" />}
2293
+ {t('examForm.actions.create')}
2294
+ </Button>
2295
+ </SheetFooter>
2296
+ </form>
2297
+ </SheetContent>
2298
+ </Sheet>
2299
+
1055
2300
  {/* Delete Dialog */}
1056
2301
  <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1057
- <DialogContent>
2302
+ <DialogContent className="max-w-3xl">
1058
2303
  <DialogHeader>
1059
2304
  <DialogTitle className="flex items-center gap-2">
1060
2305
  <AlertTriangle className="size-5 text-destructive" />{' '}