@hed-hog/lms 0.0.303 → 0.0.305

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 (462) 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-lead.dto.d.ts +4 -0
  119. package/dist/enterprise/dto/add-enterprise-lead.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-lead.dto.js +22 -0
  121. package/dist/enterprise/dto/add-enterprise-lead.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts +5 -0
  123. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  125. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  126. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  127. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  129. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  130. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  131. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  133. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  135. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  137. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  139. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  141. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  142. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  143. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  144. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  145. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  146. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  147. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.controller.js +311 -0
  149. package/dist/enterprise/enterprise.controller.js.map +1 -0
  150. package/dist/enterprise/enterprise.module.d.ts +3 -0
  151. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.module.js +25 -0
  153. package/dist/enterprise/enterprise.module.js.map +1 -0
  154. package/dist/enterprise/enterprise.service.d.ts +282 -0
  155. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  156. package/dist/enterprise/enterprise.service.js +627 -0
  157. package/dist/enterprise/enterprise.service.js.map +1 -0
  158. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  159. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.controller.js +76 -0
  161. package/dist/evaluation/evaluation.controller.js.map +1 -0
  162. package/dist/evaluation/evaluation.module.d.ts +3 -0
  163. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.module.js +25 -0
  165. package/dist/evaluation/evaluation.module.js.map +1 -0
  166. package/dist/evaluation/evaluation.service.d.ts +67 -0
  167. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  168. package/dist/evaluation/evaluation.service.js +378 -0
  169. package/dist/evaluation/evaluation.service.js.map +1 -0
  170. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  171. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  173. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  174. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  175. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/create-exam.dto.js +63 -0
  177. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  178. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  179. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  181. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  182. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  183. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  185. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  186. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  187. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  191. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  193. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  197. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  198. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  199. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  200. package/dist/exam/dto/update-exam.dto.js +9 -0
  201. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  202. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  203. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.controller.js +84 -0
  205. package/dist/exam/exam-attempt.controller.js.map +1 -0
  206. package/dist/exam/exam-attempt.service.d.ts +302 -0
  207. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  208. package/dist/exam/exam-attempt.service.js +776 -0
  209. package/dist/exam/exam-attempt.service.js.map +1 -0
  210. package/dist/exam/exam.controller.d.ts +162 -0
  211. package/dist/exam/exam.controller.d.ts.map +1 -0
  212. package/dist/exam/exam.controller.js +158 -0
  213. package/dist/exam/exam.controller.js.map +1 -0
  214. package/dist/exam/exam.module.d.ts +3 -0
  215. package/dist/exam/exam.module.d.ts.map +1 -0
  216. package/dist/exam/exam.module.js +27 -0
  217. package/dist/exam/exam.module.js.map +1 -0
  218. package/dist/exam/exam.service.d.ts +179 -0
  219. package/dist/exam/exam.service.d.ts.map +1 -0
  220. package/dist/exam/exam.service.js +597 -0
  221. package/dist/exam/exam.service.js.map +1 -0
  222. package/dist/index.d.ts +28 -0
  223. package/dist/index.d.ts.map +1 -1
  224. package/dist/index.js +28 -0
  225. package/dist/index.js.map +1 -1
  226. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  227. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  229. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  230. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  231. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  232. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  233. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  234. package/dist/instructor/instructor.controller.d.ts +52 -0
  235. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  236. package/dist/instructor/instructor.controller.js +98 -0
  237. package/dist/instructor/instructor.controller.js.map +1 -0
  238. package/dist/instructor/instructor.module.d.ts +3 -0
  239. package/dist/instructor/instructor.module.d.ts.map +1 -0
  240. package/dist/instructor/instructor.module.js +25 -0
  241. package/dist/instructor/instructor.module.js.map +1 -0
  242. package/dist/instructor/instructor.service.d.ts +79 -0
  243. package/dist/instructor/instructor.service.d.ts.map +1 -0
  244. package/dist/instructor/instructor.service.js +528 -0
  245. package/dist/instructor/instructor.service.js.map +1 -0
  246. package/dist/lms.module.d.ts.map +1 -1
  247. package/dist/lms.module.js +36 -4
  248. package/dist/lms.module.js.map +1 -1
  249. package/dist/reports/reports.controller.d.ts +69 -0
  250. package/dist/reports/reports.controller.d.ts.map +1 -0
  251. package/dist/reports/reports.controller.js +40 -0
  252. package/dist/reports/reports.controller.js.map +1 -0
  253. package/dist/reports/reports.module.d.ts +3 -0
  254. package/dist/reports/reports.module.d.ts.map +1 -0
  255. package/dist/reports/reports.module.js +25 -0
  256. package/dist/reports/reports.module.js.map +1 -0
  257. package/dist/reports/reports.service.d.ts +80 -0
  258. package/dist/reports/reports.service.d.ts.map +1 -0
  259. package/dist/reports/reports.service.js +366 -0
  260. package/dist/reports/reports.service.js.map +1 -0
  261. package/dist/training/dto/create-training.dto.d.ts +19 -0
  262. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/create-training.dto.js +98 -0
  264. package/dist/training/dto/create-training.dto.js.map +1 -0
  265. package/dist/training/dto/update-training.dto.d.ts +6 -0
  266. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  267. package/dist/training/dto/update-training.dto.js +9 -0
  268. package/dist/training/dto/update-training.dto.js.map +1 -0
  269. package/dist/training/training.controller.d.ts +195 -0
  270. package/dist/training/training.controller.d.ts.map +1 -0
  271. package/dist/training/training.controller.js +104 -0
  272. package/dist/training/training.controller.js.map +1 -0
  273. package/dist/training/training.module.d.ts +3 -0
  274. package/dist/training/training.module.d.ts.map +1 -0
  275. package/dist/training/training.module.js +25 -0
  276. package/dist/training/training.module.js.map +1 -0
  277. package/dist/training/training.service.d.ts +213 -0
  278. package/dist/training/training.service.d.ts.map +1 -0
  279. package/dist/training/training.service.js +497 -0
  280. package/dist/training/training.service.js.map +1 -0
  281. package/hedhog/data/dashboard.yaml +6 -0
  282. package/hedhog/data/dashboard_component.yaml +153 -0
  283. package/hedhog/data/dashboard_component_role.yaml +97 -0
  284. package/hedhog/data/dashboard_item.yaml +167 -0
  285. package/hedhog/data/dashboard_role.yaml +6 -0
  286. package/hedhog/data/instructor_qualification.yaml +16 -0
  287. package/hedhog/data/menu.yaml +129 -19
  288. package/hedhog/data/role.yaml +25 -1
  289. package/hedhog/data/route.yaml +867 -0
  290. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  291. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  292. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  293. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  294. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  295. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  296. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  297. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  298. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  299. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  300. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  301. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  302. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  303. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  304. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  305. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  306. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  307. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  308. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  309. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  310. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  311. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  312. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  313. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  322. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  323. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  324. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  325. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  326. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  327. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  328. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  329. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +57 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +266 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +187 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  348. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  349. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  350. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  351. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  352. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  353. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  354. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  355. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  356. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  357. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  358. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  359. package/hedhog/frontend/app/page.tsx.ejs +5 -1222
  360. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  361. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  362. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  363. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  364. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  365. package/hedhog/frontend/messages/en.json +1606 -111
  366. package/hedhog/frontend/messages/pt.json +1636 -134
  367. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  368. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  369. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  370. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  371. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  372. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  373. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  374. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  375. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  376. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  377. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  378. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  379. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  380. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  381. package/hedhog/table/course.yaml +28 -10
  382. package/hedhog/table/course_class_group.yaml +8 -0
  383. package/hedhog/table/course_class_session.yaml +33 -0
  384. package/hedhog/table/course_instructor.yaml +27 -0
  385. package/hedhog/table/enterprise.yaml +29 -0
  386. package/hedhog/table/enterprise_class_group.yaml +20 -0
  387. package/hedhog/table/enterprise_course.yaml +23 -0
  388. package/hedhog/table/enterprise_student.yaml +24 -0
  389. package/hedhog/table/enterprise_user.yaml +35 -0
  390. package/hedhog/table/instructor_qualification.yaml +26 -0
  391. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  392. package/hedhog/table/question.yaml +6 -0
  393. package/package.json +6 -6
  394. package/src/certificate/certificate.controller.ts +83 -0
  395. package/src/certificate/certificate.module.ts +13 -0
  396. package/src/certificate/certificate.service.ts +413 -0
  397. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  398. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  399. package/src/class-group/class-group.controller.ts +189 -0
  400. package/src/class-group/class-group.module.ts +12 -0
  401. package/src/class-group/class-group.service.ts +1802 -0
  402. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  403. package/src/class-group/dto/create-session.dto.ts +102 -0
  404. package/src/class-group/dto/enrollment.dto.ts +70 -0
  405. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  406. package/src/class-group/dto/update-session.dto.ts +9 -0
  407. package/src/course/course-structure.controller.ts +85 -0
  408. package/src/course/course-structure.service.ts +525 -0
  409. package/src/course/course.controller.ts +69 -0
  410. package/src/course/course.module.ts +15 -0
  411. package/src/course/course.service.ts +920 -0
  412. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  413. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  414. package/src/course/dto/create-course.dto.ts +111 -0
  415. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  416. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  417. package/src/course/dto/update-course.dto.ts +4 -0
  418. package/src/dashboard/dashboard.controller.ts +14 -0
  419. package/src/dashboard/dashboard.module.ts +12 -0
  420. package/src/dashboard/dashboard.service.ts +726 -0
  421. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  422. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  423. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  424. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  425. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  426. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  427. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  428. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  429. package/src/enterprise/enterprise.controller.ts +233 -0
  430. package/src/enterprise/enterprise.module.ts +12 -0
  431. package/src/enterprise/enterprise.service.ts +712 -0
  432. package/src/evaluation/evaluation.controller.ts +44 -0
  433. package/src/evaluation/evaluation.module.ts +12 -0
  434. package/src/evaluation/evaluation.service.ts +394 -0
  435. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  436. package/src/exam/dto/create-exam.dto.ts +41 -0
  437. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  438. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  439. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  440. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  441. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  442. package/src/exam/dto/update-exam.dto.ts +4 -0
  443. package/src/exam/exam-attempt.controller.ts +65 -0
  444. package/src/exam/exam-attempt.service.ts +1008 -0
  445. package/src/exam/exam.controller.ts +102 -0
  446. package/src/exam/exam.module.ts +14 -0
  447. package/src/exam/exam.service.ts +784 -0
  448. package/src/index.ts +29 -0
  449. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  450. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  451. package/src/instructor/instructor.controller.ts +73 -0
  452. package/src/instructor/instructor.module.ts +12 -0
  453. package/src/instructor/instructor.service.ts +646 -0
  454. package/src/lms.module.ts +36 -4
  455. package/src/reports/reports.controller.ts +14 -0
  456. package/src/reports/reports.module.ts +12 -0
  457. package/src/reports/reports.service.ts +485 -0
  458. package/src/training/dto/create-training.dto.ts +81 -0
  459. package/src/training/dto/update-training.dto.ts +4 -0
  460. package/src/training/training.controller.ts +68 -0
  461. package/src/training/training.module.ts +12 -0
  462. package/src/training/training.service.ts +574 -0
@@ -1,16 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { Page, PageHeader } from '@/components/entity-list';
4
- import { Badge } from '@/components/ui/badge';
3
+ import { createDefaultTemplate } from '@/app/(app)/(libraries)/lms/_lib/editor/types';
4
+ import { CreateLmsPersonSheet } from '@/app/(app)/(libraries)/lms/_components/create-lms-person-sheet';
5
+ import { EmptyState, Page, PageHeader } from '@/components/entity-list';
5
6
  import { Button } from '@/components/ui/button';
6
- import {
7
- Card,
8
- CardContent,
9
- CardDescription,
10
- CardHeader,
11
- CardTitle,
12
- } from '@/components/ui/card';
13
- import { Checkbox } from '@/components/ui/checkbox';
14
7
  import {
15
8
  Dialog,
16
9
  DialogContent,
@@ -19,235 +12,175 @@ import {
19
12
  DialogHeader,
20
13
  DialogTitle,
21
14
  } from '@/components/ui/dialog';
22
- import {
23
- Field,
24
- FieldDescription,
25
- FieldError,
26
- FieldLabel,
27
- } from '@/components/ui/field';
28
- import { Input } from '@/components/ui/input';
29
- import {
30
- Select,
31
- SelectContent,
32
- SelectItem,
33
- SelectTrigger,
34
- SelectValue,
35
- } from '@/components/ui/select';
36
- import { Separator } from '@/components/ui/separator';
15
+ import { Form } from '@/components/ui/form';
37
16
  import { Skeleton } from '@/components/ui/skeleton';
38
- import { Switch } from '@/components/ui/switch';
39
- import { Textarea } from '@/components/ui/textarea';
17
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
40
18
  import { zodResolver } from '@hookform/resolvers/zod';
41
- import { motion } from 'framer-motion';
42
- import {
43
- AlertTriangle,
44
- Award,
45
- BarChart3,
46
- BookOpen,
47
- CalendarDays,
48
- FileCheck,
49
- GraduationCap,
50
- Hash,
51
- ImageIcon,
52
- Layers,
53
- LayoutDashboard,
54
- Loader2,
55
- Percent,
56
- Save,
57
- Trash2,
58
- TrendingUp,
59
- Upload,
60
- UserCheck,
61
- Users,
62
- Video,
63
- XCircle,
64
- } from 'lucide-react';
19
+ import { AlertTriangle, BookOpen, Loader2, Save } from 'lucide-react';
65
20
  import { useTranslations } from 'next-intl';
66
- import { usePathname, useRouter } from 'next/navigation';
67
- import { use, useEffect, useRef, useState } from 'react';
68
- import { Controller, useForm } from 'react-hook-form';
69
- import {
70
- Bar,
71
- BarChart,
72
- CartesianGrid,
73
- ResponsiveContainer,
74
- Tooltip,
75
- XAxis,
76
- YAxis,
77
- } from 'recharts';
21
+ import { useRouter } from 'next/navigation';
22
+ import { type ChangeEvent, use, useEffect, useMemo, useState } from 'react';
23
+ import { useForm } from 'react-hook-form';
78
24
  import { toast } from 'sonner';
79
25
  import { z } from 'zod';
80
26
 
81
- type MediaUploadFieldProps = {
82
- label: string;
27
+ import { CourseCertificateCard } from './_components/CourseCertificateCard';
28
+ import { CourseClassificationCard } from './_components/CourseClassificationCard';
29
+ import { CourseContentCard } from './_components/CourseContentCard';
30
+ import { CourseDangerZoneCard } from './_components/CourseDangerZoneCard';
31
+ import { CourseFlagsCard } from './_components/CourseFlagsCard';
32
+ import { CourseMainInfoCard } from './_components/CourseMainInfoCard';
33
+ import { CourseMediaCard } from './_components/CourseMediaCard';
34
+ import { CourseRelationsCard } from './_components/CourseRelationsCard';
35
+ import { CourseSummaryCard } from './_components/CourseSummaryCard';
36
+ import type {
37
+ CourseEditFormValues,
38
+ PickerOption,
39
+ } from './_components/course-edit-types';
40
+
41
+ const API_COURSES_CACHE_KEY = 'lms:courses:api-cache';
42
+
43
+ type ApiCourseDetail = {
44
+ id: number;
45
+ code: string;
46
+ slug: string;
47
+ title: string;
83
48
  description: string;
84
- uploadLabel: string;
85
- changeLabel: string;
86
- removeLabel: string;
87
- emptyTitle: string;
88
- preview: string | null;
89
- previewAlt: string;
90
- inputRef: React.RefObject<HTMLInputElement | null>;
91
- onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
92
- onClear: () => void;
93
- icon: React.ComponentType<{ className?: string }>;
94
- aspectClassName: string;
95
- compactPreview?: boolean;
49
+ primaryColor?: string | null;
50
+ secondaryColor?: string | null;
51
+ level: 'beginner' | 'intermediate' | 'advanced';
52
+ status: 'draft' | 'published' | 'archived';
53
+ offeringType: 'scheduled' | 'on_demand' | 'blended';
54
+ categories: string[];
55
+ isFeatured: boolean;
56
+ hasCertificate: boolean;
57
+ isListed: boolean;
58
+ enrollmentCount: number;
59
+ requirements: string;
60
+ objectives: string;
61
+ targetAudience: string;
62
+ lessonCount: number;
63
+ sessionCount: number;
64
+ averageCompletion: number;
65
+ certificatesIssued: number;
66
+ instructorIds?: number[];
67
+ instructors?: Array<{
68
+ id: number;
69
+ name: string;
70
+ avatarId: number | null;
71
+ }>;
72
+ logoFileId?: number | null;
73
+ logoFilename?: string | null;
74
+ bannerFileId?: number | null;
75
+ bannerFilename?: string | null;
76
+ certificateModel?: string | null;
96
77
  };
97
78
 
98
- function MediaUploadField({
99
- label,
100
- description,
101
- uploadLabel,
102
- changeLabel,
103
- removeLabel,
104
- emptyTitle,
105
- preview,
106
- previewAlt,
107
- inputRef,
108
- onInputChange,
109
- onClear,
110
- icon: Icon,
111
- aspectClassName,
112
- compactPreview = false,
113
- }: MediaUploadFieldProps) {
114
- return (
115
- <Field>
116
- <FieldLabel>{label}</FieldLabel>
117
- <FieldDescription>{description}</FieldDescription>
118
- <input
119
- ref={inputRef}
120
- type="file"
121
- accept="image/*"
122
- className="hidden"
123
- onChange={onInputChange}
124
- />
125
- <div className="rounded-xl border border-border/70 bg-muted/20 p-4">
126
- <div
127
- className={
128
- compactPreview
129
- ? 'flex flex-col gap-4 sm:flex-row'
130
- : 'flex flex-col gap-4'
131
- }
132
- >
133
- <div
134
- className={[
135
- 'overflow-hidden rounded-xl border border-dashed border-border/80 bg-background',
136
- compactPreview ? 'w-full shrink-0 sm:w-28' : 'w-full',
137
- ].join(' ')}
138
- >
139
- {preview ? (
140
- <img
141
- src={preview}
142
- alt={previewAlt}
143
- className={`${aspectClassName} w-full object-cover`}
144
- />
145
- ) : (
146
- <div
147
- className={`${aspectClassName} flex w-full flex-col items-center justify-center gap-2 px-4 text-center`}
148
- >
149
- <div className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
150
- <Icon className="size-5" />
151
- </div>
152
- <div className="space-y-1">
153
- <p className="text-sm font-medium">{emptyTitle}</p>
154
- <p className="text-xs text-muted-foreground">{uploadLabel}</p>
155
- </div>
156
- </div>
157
- )}
158
- </div>
79
+ type ApiCategory = {
80
+ id: number;
81
+ slug: string;
82
+ name: string;
83
+ };
159
84
 
160
- <div className="flex min-w-0 flex-1 flex-col justify-between gap-3">
161
- <div className="space-y-1">
162
- <p className="text-sm font-medium text-foreground">{label}</p>
163
- <p className="text-sm text-muted-foreground">{description}</p>
164
- </div>
85
+ type ApiCategoryList = {
86
+ data: ApiCategory[];
87
+ total: number;
88
+ page: number;
89
+ pageSize: number;
90
+ };
165
91
 
166
- <div className="flex flex-col gap-2 sm:flex-row">
167
- <Button
168
- type="button"
169
- variant={preview ? 'outline' : 'default'}
170
- className="w-full gap-2 sm:w-auto"
171
- onClick={() => inputRef.current?.click()}
172
- >
173
- <Upload className="size-4" />
174
- {preview ? changeLabel : uploadLabel}
175
- </Button>
176
- {preview && (
177
- <Button
178
- type="button"
179
- variant="outline"
180
- className="w-full gap-2 text-destructive hover:text-destructive sm:w-auto"
181
- onClick={onClear}
182
- >
183
- <XCircle className="size-4" />
184
- {removeLabel}
185
- </Button>
186
- )}
187
- </div>
188
- </div>
189
- </div>
190
- </div>
191
- </Field>
192
- );
193
- }
92
+ type ApiInstructor = {
93
+ id: number;
94
+ personId: number;
95
+ name: string;
96
+ avatarId?: number | null;
97
+ qualificationSlugs: string[];
98
+ };
99
+
100
+ type ApiInstructorList = {
101
+ data: ApiInstructor[];
102
+ total: number;
103
+ page: number;
104
+ pageSize: number;
105
+ };
106
+
107
+ type ApiCertificateTemplate = {
108
+ id: number;
109
+ name: string;
110
+ slug?: string | null;
111
+ description?: string | null;
112
+ status?: 'draft' | 'active' | 'inactive';
113
+ };
194
114
 
195
- // ── Navigation ──────────────────────────────────────────────────────────────────
115
+ type ApiCertificateTemplateList = {
116
+ data: ApiCertificateTemplate[];
117
+ total: number;
118
+ page: number;
119
+ pageSize: number;
120
+ lastPage?: number;
121
+ };
196
122
 
197
- const NAV_ITEMS = [
198
- { label: 'Dashboard', href: '/', icon: LayoutDashboard },
199
- { label: 'Cursos', href: '/cursos', icon: BookOpen },
200
- { label: 'Turmas', href: '/turmas', icon: Users },
201
- { label: 'Exames', href: '/exames', icon: FileCheck },
202
- { label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
203
- { label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
204
- ];
123
+ type Locale = {
124
+ id?: number;
125
+ code: string;
126
+ name: string;
127
+ };
205
128
 
206
- // ── Schema ──────────────────────────────────────────────────────────────────────
129
+ type CursoDetailView = {
130
+ id: number;
131
+ codigo: string;
132
+ nomeInterno: string;
133
+ tituloComercial: string;
134
+ descricaoPublica: string;
135
+ nivel: CourseEditFormValues['nivel'];
136
+ status: CourseEditFormValues['status'];
137
+ tipoOferta: CourseEditFormValues['tipoOferta'];
138
+ categorias: string[];
139
+ primaryColor: string;
140
+ secondaryColor: string;
141
+ instrutores: string[];
142
+ preRequisitos: string;
143
+ modeloCertificado: string;
144
+ certificado: boolean;
145
+ destaque: boolean;
146
+ listado: boolean;
147
+ objetivos: string;
148
+ publicoAlvo: string;
149
+ totalAlunos: number;
150
+ conclusaoMedia: number;
151
+ totalAulas: number;
152
+ totalSessoes: number;
153
+ certificadosEmitidos: number;
154
+ };
207
155
 
208
156
  function getCursoEditSchema(t: (key: string) => string) {
209
157
  return z.object({
210
158
  codigo: z
211
159
  .string()
160
+ .trim()
212
161
  .min(2, t('validation.codeMin'))
213
162
  .max(16, t('validation.codeMax'))
214
- .regex(/^[A-Z0-9-]+$/i, t('validation.codeFormat')),
215
- nomeInterno: z.string().min(3, t('validation.internalNameMin')),
216
- tituloComercial: z.string().min(3, t('validation.titleMin')),
217
- descricaoPublica: z.string().min(10, t('validation.descriptionMin')),
218
- nivel: z.enum(['iniciante', 'intermediario', 'avancado'], {
219
- errorMap: () => ({ message: t('validation.levelRequired') }),
220
- }),
221
- status: z.enum(['ativo', 'rascunho', 'arquivado'], {
222
- errorMap: () => ({ message: t('validation.statusRequired') }),
223
- }),
163
+ .regex(/^[A-Za-z0-9-]+$/, t('validation.codeFormat')),
164
+ nomeInterno: z.string().trim().min(3, t('validation.internalNameMin')),
165
+ tituloComercial: z.string().trim().min(3, t('validation.titleMin')),
166
+ descricaoPublica: z.string().trim().min(10, t('validation.descriptionMin')),
167
+ objetivos: z.string().optional(),
168
+ publicoAlvo: z.string().optional(),
169
+ nivel: z.enum(['iniciante', 'intermediario', 'avancado']),
170
+ status: z.enum(['ativo', 'rascunho', 'arquivado']),
171
+ tipoOferta: z.enum(['agendado', 'sob_demanda', 'hibrido']),
224
172
  categorias: z.array(z.string()).min(1, t('validation.categoryRequired')),
225
- instrutores: z.array(z.string()),
226
- preRequisitos: z.string(),
227
- modeloCertificado: z.string(),
228
- destaque: z.boolean(),
229
- certificado: z.boolean(),
230
- listado: z.boolean(),
173
+ primaryColor: z.string().regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
174
+ secondaryColor: z.string().regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
175
+ instrutores: z.array(z.string()).optional(),
176
+ preRequisitos: z.string().optional(),
177
+ modeloCertificado: z.string().optional(),
178
+ certificado: z.boolean().default(false),
179
+ destaque: z.boolean().default(false),
180
+ listado: z.boolean().default(false),
231
181
  });
232
182
  }
233
183
 
234
- type CursoEditForm = z.infer<ReturnType<typeof getCursoEditSchema>>;
235
-
236
- // ── Constants ───────────────────────────────────────────────────────────────────
237
-
238
- function getCategorias(t: (key: string) => string) {
239
- return [
240
- t('categories.technology'),
241
- t('categories.design'),
242
- t('categories.management'),
243
- t('categories.marketing'),
244
- t('categories.finance'),
245
- t('categories.health'),
246
- t('categories.languages'),
247
- t('categories.law'),
248
- ];
249
- }
250
-
251
184
  function getNiveis(t: (key: string) => string) {
252
185
  return [
253
186
  { value: 'iniciante', label: t('levels.beginner') },
@@ -264,88 +197,171 @@ function getStatusOptions(t: (key: string) => string) {
264
197
  ];
265
198
  }
266
199
 
267
- function getStatusMap(
268
- t: (key: string) => string
269
- ): Record<
270
- string,
271
- { label: string; variant: 'default' | 'secondary' | 'outline' }
272
- > {
273
- return {
274
- ativo: { label: t('status.active'), variant: 'default' },
275
- rascunho: { label: t('status.draft'), variant: 'secondary' },
276
- arquivado: { label: t('status.archived'), variant: 'outline' },
277
- };
200
+ function getOfferingTypeOptions(t: (key: string) => string) {
201
+ return [
202
+ {
203
+ value: 'sob_demanda',
204
+ label: t('offeringType.options.onDemand.label'),
205
+ description: t('offeringType.options.onDemand.description'),
206
+ },
207
+ {
208
+ value: 'agendado',
209
+ label: t('offeringType.options.scheduled.label'),
210
+ description: t('offeringType.options.scheduled.description'),
211
+ },
212
+ {
213
+ value: 'hibrido',
214
+ label: t('offeringType.options.blended.label'),
215
+ description: t('offeringType.options.blended.description'),
216
+ },
217
+ ] as const;
278
218
  }
279
219
 
280
- const INSTRUTORES = [
281
- { id: 'inst-1', nome: 'Ana Paula Mendes' },
282
- { id: 'inst-2', nome: 'Carlos Ferreira' },
283
- { id: 'inst-3', nome: 'Juliana Santos' },
284
- { id: 'inst-4', nome: 'Roberto Lima' },
285
- { id: 'inst-5', nome: 'Mariana Costa' },
286
- { id: 'inst-6', nome: 'Pedro Almeida' },
287
- ];
220
+ function normalizeEnumValue(value?: string | null) {
221
+ return String(value ?? '')
222
+ .trim()
223
+ .normalize('NFD')
224
+ .replace(/[\u0300-\u036f]/g, '')
225
+ .toLowerCase();
226
+ }
288
227
 
289
- function getModelosCertificado(t: (key: string) => string) {
290
- return [
291
- { value: 'padrao', label: t('certificateModels.standard') },
292
- { value: 'premium', label: t('certificateModels.premium') },
293
- { value: 'minimalista', label: t('certificateModels.minimalist') },
294
- { value: 'tecnologia', label: t('certificateModels.technology') },
295
- { value: 'corporativo', label: t('certificateModels.corporate') },
296
- ];
228
+ function toPtLevel(level: ApiCourseDetail['level']): CourseEditFormValues['nivel'] {
229
+ const normalized = normalizeEnumValue(level);
230
+ if (normalized === 'beginner' || normalized === 'iniciante') return 'iniciante';
231
+ if (normalized === 'intermediate' || normalized === 'intermediario') {
232
+ return 'intermediario';
233
+ }
234
+ return 'avancado';
297
235
  }
298
236
 
299
- // ── Mock Data ───────────────────────────────────────────────────────────────────
300
-
301
- const MOCK_CURSO = {
302
- id: 1,
303
- codigo: 'REACT-ADV',
304
- nomeInterno: 'react-avancado',
305
- tituloComercial: 'React Avancado',
306
- descricaoPublica:
307
- 'Curso completo de React com hooks, context e patterns avancados para aplicacoes modernas. Aprenda a construir interfaces performaticas e escalaveis com as melhores praticas do mercado.',
308
- nivel: 'avancado' as const,
309
- status: 'ativo' as const,
310
- categorias: ['Tecnologia'],
311
- instrutores: ['inst-1', 'inst-3'],
312
- preRequisitos: 'JavaScript ES6+, HTML/CSS basico, React Fundamentals',
313
- modeloCertificado: 'premium',
314
- destaque: true,
315
- certificado: true,
316
- listado: true,
317
- // KPIs
318
- totalAlunos: 245,
319
- conclusaoMedia: 78,
320
- totalAulas: 48,
321
- totalSessoes: 96,
322
- certificadosEmitidos: 191,
323
- };
237
+ function toApiLevel(level: CourseEditFormValues['nivel']) {
238
+ if (level === 'iniciante') return 'beginner';
239
+ if (level === 'intermediario') return 'intermediate';
240
+ return 'advanced';
241
+ }
324
242
 
325
- const progressData = [
326
- { modulo: 'Mod 1', progresso: 95 },
327
- { modulo: 'Mod 2', progresso: 88 },
328
- { modulo: 'Mod 3', progresso: 82 },
329
- { modulo: 'Mod 4', progresso: 74 },
330
- { modulo: 'Mod 5', progresso: 68 },
331
- { modulo: 'Mod 6', progresso: 55 },
332
- { modulo: 'Mod 7', progresso: 42 },
333
- { modulo: 'Mod 8', progresso: 31 },
334
- ];
335
-
336
- // ── Animations ──────────────────────────────────────────────────────────────────
337
-
338
- const stagger = {
339
- hidden: {},
340
- show: { transition: { staggerChildren: 0.06 } },
341
- };
243
+ function toPtStatus(status: ApiCourseDetail['status']): CourseEditFormValues['status'] {
244
+ const normalized = normalizeEnumValue(status);
245
+ if (normalized === 'published' || normalized === 'active' || normalized === 'ativo') {
246
+ return 'ativo';
247
+ }
248
+ if (normalized === 'archived' || normalized === 'arquivado') {
249
+ return 'arquivado';
250
+ }
251
+ return 'rascunho';
252
+ }
342
253
 
343
- const fadeUp = {
344
- hidden: { opacity: 0, y: 16 },
345
- show: { opacity: 1, y: 0, transition: { duration: 0.35 } },
346
- };
254
+ function toApiStatus(status: CourseEditFormValues['status']) {
255
+ if (status === 'ativo') return 'published';
256
+ if (status === 'arquivado') return 'archived';
257
+ return 'draft';
258
+ }
259
+
260
+ function toPtOfferingType(
261
+ value: ApiCourseDetail['offeringType']
262
+ ): CourseEditFormValues['tipoOferta'] {
263
+ const normalized = normalizeEnumValue(value);
264
+ if (normalized === 'scheduled' || normalized === 'agendado') {
265
+ return 'agendado';
266
+ }
267
+ if (normalized === 'blended' || normalized === 'hibrido') {
268
+ return 'hibrido';
269
+ }
270
+ return 'sob_demanda';
271
+ }
272
+
273
+ function toApiOfferingType(value: CourseEditFormValues['tipoOferta']) {
274
+ if (value === 'agendado') return 'scheduled';
275
+ if (value === 'hibrido') return 'blended';
276
+ return 'on_demand';
277
+ }
278
+
279
+ function getContrastColor(hex: string) {
280
+ const cleaned = hex.replace('#', '');
281
+ if (cleaned.length !== 6) return '#FFFFFF';
282
+
283
+ const r = parseInt(cleaned.slice(0, 2), 16);
284
+ const g = parseInt(cleaned.slice(2, 4), 16);
285
+ const b = parseInt(cleaned.slice(4, 6), 16);
286
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
287
+
288
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
289
+ }
290
+
291
+ function slugify(value: string) {
292
+ return value
293
+ .trim()
294
+ .toLowerCase()
295
+ .normalize('NFD')
296
+ .replace(/[\u0300-\u036f]/g, '')
297
+ .replace(/[^a-z0-9]+/g, '-')
298
+ .replace(/^-+|-+$/g, '');
299
+ }
300
+
301
+ function clearCoursesListCache() {
302
+ if (typeof window === 'undefined') return;
303
+ window.localStorage.removeItem(API_COURSES_CACHE_KEY);
304
+ }
305
+
306
+ function getInstructorAvatarUrl(avatarId?: number | null) {
307
+ return typeof avatarId === 'number' && avatarId > 0
308
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
309
+ : null;
310
+ }
311
+
312
+ function mapApiCourseToView(course: ApiCourseDetail): CursoDetailView {
313
+ return {
314
+ id: course.id,
315
+ codigo: course.code,
316
+ nomeInterno: course.slug,
317
+ tituloComercial: course.title,
318
+ descricaoPublica: course.description ?? '',
319
+ nivel: toPtLevel(course.level),
320
+ status: toPtStatus(course.status),
321
+ tipoOferta: toPtOfferingType(course.offeringType),
322
+ categorias: course.categories ?? [],
323
+ primaryColor: course.primaryColor || '#1D4ED8',
324
+ secondaryColor: course.secondaryColor || '#111827',
325
+ instrutores: (course.instructorIds ?? []).map(String),
326
+ preRequisitos: course.requirements ?? '',
327
+ modeloCertificado: course.certificateModel ?? '',
328
+ certificado: course.hasCertificate ?? false,
329
+ destaque: course.isFeatured ?? false,
330
+ listado: course.isListed ?? false,
331
+ objetivos: course.objectives ?? '',
332
+ publicoAlvo: course.targetAudience ?? '',
333
+ totalAlunos: course.enrollmentCount ?? 0,
334
+ conclusaoMedia: course.averageCompletion ?? 0,
335
+ totalAulas: course.lessonCount ?? 0,
336
+ totalSessoes: course.sessionCount ?? 0,
337
+ certificadosEmitidos: course.certificatesIssued ?? 0,
338
+ };
339
+ }
340
+
341
+ function LoadingSkeleton() {
342
+ return (
343
+ <div className="space-y-6">
344
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
345
+ {Array.from({ length: 4 }).map((_, index) => (
346
+ <Skeleton key={index} className="h-24 rounded-2xl" />
347
+ ))}
348
+ </div>
347
349
 
348
- // ── Component ───────────────────────────────────────────────────────────────────
350
+ <div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
351
+ <div className="space-y-6">
352
+ {Array.from({ length: 4 }).map((_, index) => (
353
+ <Skeleton key={index} className="h-64 rounded-2xl" />
354
+ ))}
355
+ </div>
356
+ <div className="space-y-6">
357
+ {Array.from({ length: 4 }).map((_, index) => (
358
+ <Skeleton key={index} className="h-56 rounded-2xl" />
359
+ ))}
360
+ </div>
361
+ </div>
362
+ </div>
363
+ );
364
+ }
349
365
 
350
366
  export default function CursoEditPage({
351
367
  params,
@@ -353,843 +369,696 @@ export default function CursoEditPage({
353
369
  params: Promise<{ id: string }>;
354
370
  }) {
355
371
  const { id } = use(params);
356
- const pathname = usePathname();
357
372
  const router = useRouter();
358
373
  const t = useTranslations('lms.CursoEditPage');
374
+ const { request, currentLocaleCode, locales } = useApp();
359
375
 
360
- const CATEGORIAS = getCategorias(t);
361
- const NIVEIS = getNiveis(t);
362
- const STATUS_OPTIONS = getStatusOptions(t);
363
- const STATUS_MAP = getStatusMap(t);
364
- const MODELOS_CERTIFICADO = getModelosCertificado(t);
365
-
366
- const [loading, setLoading] = useState(true);
367
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
368
376
  const [saving, setSaving] = useState(false);
369
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
370
377
  const [deleting, setDeleting] = useState(false);
371
-
372
- // File uploads (client-side preview only)
378
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
379
+ const [instructorSheetOpen, setInstructorSheetOpen] = useState(false);
373
380
  const [logoPreview, setLogoPreview] = useState<string | null>(null);
374
381
  const [bannerPreview, setBannerPreview] = useState<string | null>(null);
375
- const logoInputRef = useRef<HTMLInputElement>(null);
376
- const bannerInputRef = useRef<HTMLInputElement>(null);
382
+ const [uploadingLogo, setUploadingLogo] = useState(false);
383
+ const [uploadingBanner, setUploadingBanner] = useState(false);
384
+ const [createdCategoryOptions, setCreatedCategoryOptions] = useState<PickerOption[]>([]);
385
+ const [createdTemplateOptions, setCreatedTemplateOptions] = useState<PickerOption[]>([]);
386
+ const [persistedCertificateModel, setPersistedCertificateModel] = useState('');
377
387
 
378
- const form = useForm<CursoEditForm>({
388
+ const form = useForm<CourseEditFormValues>({
379
389
  resolver: zodResolver(getCursoEditSchema(t)),
380
390
  defaultValues: {
381
391
  codigo: '',
382
392
  nomeInterno: '',
383
393
  tituloComercial: '',
384
394
  descricaoPublica: '',
395
+ objetivos: '',
396
+ publicoAlvo: '',
385
397
  nivel: 'iniciante',
386
398
  status: 'rascunho',
399
+ tipoOferta: 'sob_demanda',
387
400
  categorias: [],
401
+ primaryColor: '#1D4ED8',
402
+ secondaryColor: '#111827',
388
403
  instrutores: [],
389
404
  preRequisitos: '',
390
405
  modeloCertificado: '',
406
+ certificado: false,
391
407
  destaque: false,
392
- certificado: true,
393
408
  listado: false,
394
409
  },
395
410
  });
396
411
 
397
- // Simulate loading + populate form
412
+ const NIVEIS = useMemo(() => getNiveis(t), [t]);
413
+ const STATUS_OPTIONS = useMemo(() => getStatusOptions(t), [t]);
414
+ const OFFERING_TYPE_OPTIONS = useMemo(() => getOfferingTypeOptions(t), [t]);
415
+
416
+ const {
417
+ data: apiCourse,
418
+ isLoading,
419
+ isFetching,
420
+ refetch: refetchCourse,
421
+ } = useQuery<ApiCourseDetail>({
422
+ queryKey: ['lms-course-detail', id],
423
+ queryFn: async () => {
424
+ const response = await request<ApiCourseDetail>({
425
+ url: `/lms/courses/${id}`,
426
+ method: 'GET',
427
+ });
428
+ return response.data;
429
+ },
430
+ });
431
+
432
+ const { data: categoryListData, refetch: refetchCategoryOptions } =
433
+ useQuery<ApiCategoryList>({
434
+ queryKey: ['lms-edit-course-categories'],
435
+ queryFn: async () => {
436
+ const response = await request<ApiCategoryList | ApiCategory[]>({
437
+ url: '/category',
438
+ method: 'GET',
439
+ params: {
440
+ page: 1,
441
+ pageSize: 500,
442
+ status: 'all',
443
+ },
444
+ });
445
+
446
+ const payload = response.data;
447
+ if (Array.isArray(payload)) {
448
+ return {
449
+ data: payload,
450
+ total: payload.length,
451
+ page: 1,
452
+ pageSize: payload.length,
453
+ };
454
+ }
455
+
456
+ return payload;
457
+ },
458
+ initialData: {
459
+ data: [],
460
+ total: 0,
461
+ page: 1,
462
+ pageSize: 500,
463
+ },
464
+ });
465
+
466
+ const { data: instructorListData, refetch: refetchInstructorOptions } =
467
+ useQuery<ApiInstructorList>({
468
+ queryKey: ['lms-course-edit-instructors'],
469
+ queryFn: async () => {
470
+ const response = await request<ApiInstructorList | ApiInstructor[]>({
471
+ url: '/lms/instructors',
472
+ method: 'GET',
473
+ params: {
474
+ page: 1,
475
+ pageSize: 500,
476
+ qualificationSlugs: ['course-lessons'],
477
+ },
478
+ });
479
+
480
+ const payload = response.data;
481
+ if (Array.isArray(payload)) {
482
+ return {
483
+ data: payload,
484
+ total: payload.length,
485
+ page: 1,
486
+ pageSize: payload.length,
487
+ };
488
+ }
489
+
490
+ return payload;
491
+ },
492
+ initialData: {
493
+ data: [],
494
+ total: 0,
495
+ page: 1,
496
+ pageSize: 500,
497
+ },
498
+ });
499
+
500
+ const {
501
+ data: certificateTemplateData,
502
+ refetch: refetchCertificateTemplates,
503
+ } = useQuery<ApiCertificateTemplateList>({
504
+ queryKey: ['lms-course-certificate-templates'],
505
+ queryFn: async () => {
506
+ const response = await request<
507
+ ApiCertificateTemplateList | ApiCertificateTemplate[]
508
+ >({
509
+ url: '/lms/certificates/templates',
510
+ method: 'GET',
511
+ params: {
512
+ page: 1,
513
+ pageSize: 100,
514
+ },
515
+ });
516
+
517
+ const payload = response.data;
518
+ if (Array.isArray(payload)) {
519
+ return {
520
+ data: payload,
521
+ total: payload.length,
522
+ page: 1,
523
+ pageSize: payload.length,
524
+ lastPage: 1,
525
+ };
526
+ }
527
+
528
+ return payload;
529
+ },
530
+ initialData: {
531
+ data: [],
532
+ total: 0,
533
+ page: 1,
534
+ pageSize: 100,
535
+ lastPage: 1,
536
+ },
537
+ });
538
+
539
+ useEffect(() => {
540
+ void refetchCourse();
541
+ }, [refetchCourse]);
542
+
543
+ const categoryOptions = useMemo(() => {
544
+ const serverOptions = (categoryListData?.data ?? [])
545
+ .filter((item) => !!item.slug)
546
+ .map((item) => ({
547
+ value: item.slug,
548
+ label: item.name || item.slug,
549
+ }));
550
+
551
+ const merged = [...serverOptions, ...createdCategoryOptions];
552
+ return merged.filter(
553
+ (item, index, array) =>
554
+ array.findIndex((candidate) => candidate.value === item.value) === index
555
+ );
556
+ }, [categoryListData, createdCategoryOptions]);
557
+
558
+ const instructorOptions = useMemo(() => {
559
+ const fromCourse = (apiCourse?.instructors ?? []).map((item) => ({
560
+ value: String(item.id),
561
+ label: item.name,
562
+ avatarUrl: getInstructorAvatarUrl(item.avatarId),
563
+ meta: `ID ${item.id}`,
564
+ }));
565
+
566
+ const fromDirectory = (instructorListData?.data ?? []).map((item) => ({
567
+ value: String(item.id),
568
+ label: item.name,
569
+ avatarUrl: getInstructorAvatarUrl(item.avatarId),
570
+ meta: item.qualificationSlugs?.join(' • ') || `ID ${item.id}`,
571
+ }));
572
+
573
+ const merged = [...fromCourse, ...fromDirectory];
574
+ return merged.filter(
575
+ (item, index, array) =>
576
+ array.findIndex((candidate) => candidate.value === item.value) === index
577
+ );
578
+ }, [apiCourse?.instructors, instructorListData]);
579
+
580
+ const certificateOptions = useMemo(() => {
581
+ const serverOptions = (certificateTemplateData?.data ?? []).map((item) => ({
582
+ value: item.slug || String(item.id),
583
+ label: item.name,
584
+ description: item.description || null,
585
+ meta: item.status ? `Status: ${item.status}` : null,
586
+ }));
587
+
588
+ const merged = [...serverOptions, ...createdTemplateOptions];
589
+ return merged.filter(
590
+ (item, index, array) =>
591
+ array.findIndex((candidate) => candidate.value === item.value) === index
592
+ );
593
+ }, [certificateTemplateData, createdTemplateOptions]);
594
+
595
+ const cursoData = useMemo(
596
+ () => (apiCourse ? mapApiCourseToView(apiCourse) : null),
597
+ [apiCourse]
598
+ );
599
+
398
600
  useEffect(() => {
399
- const t = setTimeout(() => {
400
- form.reset({
401
- codigo: MOCK_CURSO.codigo,
402
- nomeInterno: MOCK_CURSO.nomeInterno,
403
- tituloComercial: MOCK_CURSO.tituloComercial,
404
- descricaoPublica: MOCK_CURSO.descricaoPublica,
405
- nivel: MOCK_CURSO.nivel,
406
- status: MOCK_CURSO.status,
407
- categorias: MOCK_CURSO.categorias,
408
- instrutores: MOCK_CURSO.instrutores,
409
- preRequisitos: MOCK_CURSO.preRequisitos,
410
- modeloCertificado: MOCK_CURSO.modeloCertificado,
411
- destaque: MOCK_CURSO.destaque,
412
- certificado: MOCK_CURSO.certificado,
413
- listado: MOCK_CURSO.listado,
601
+ if (!apiCourse) return;
602
+
603
+ const nextCertificateModel =
604
+ apiCourse.certificateModel ?? persistedCertificateModel ?? '';
605
+
606
+ form.reset({
607
+ codigo: apiCourse.code,
608
+ nomeInterno: apiCourse.slug,
609
+ tituloComercial: apiCourse.title,
610
+ descricaoPublica: apiCourse.description ?? '',
611
+ objetivos: apiCourse.objectives ?? '',
612
+ publicoAlvo: apiCourse.targetAudience ?? '',
613
+ nivel: toPtLevel(apiCourse.level),
614
+ status: toPtStatus(apiCourse.status),
615
+ tipoOferta: toPtOfferingType(apiCourse.offeringType),
616
+ categorias: apiCourse.categories ?? [],
617
+ primaryColor: apiCourse.primaryColor || '#1D4ED8',
618
+ secondaryColor: apiCourse.secondaryColor || '#111827',
619
+ instrutores: (apiCourse.instructorIds ?? []).map(String),
620
+ preRequisitos: apiCourse.requirements ?? '',
621
+ modeloCertificado: nextCertificateModel,
622
+ certificado: apiCourse.hasCertificate ?? false,
623
+ destaque: apiCourse.isFeatured ?? false,
624
+ listado: apiCourse.isListed ?? false,
625
+ });
626
+
627
+ setPersistedCertificateModel(nextCertificateModel);
628
+ }, [apiCourse, form, persistedCertificateModel]);
629
+
630
+ useEffect(() => {
631
+ const previews = [
632
+ { fileId: apiCourse?.logoFileId, setter: setLogoPreview },
633
+ { fileId: apiCourse?.bannerFileId, setter: setBannerPreview },
634
+ ];
635
+
636
+ previews.forEach(({ fileId, setter }) => {
637
+ if (!fileId) return;
638
+
639
+ void (async () => {
640
+ try {
641
+ const response = await request<{ url?: string }>({
642
+ url: `/file/open/${fileId}`,
643
+ method: 'PUT',
644
+ });
645
+
646
+ if (response?.data?.url) {
647
+ setter(response.data.url);
648
+ }
649
+ } catch {
650
+ // Ignore preview failures and keep file actions available.
651
+ }
652
+ })();
653
+ });
654
+ }, [apiCourse?.bannerFileId, apiCourse?.logoFileId, request]);
655
+
656
+ async function openUploadedFile(fileId?: number | null) {
657
+ if (!fileId) return;
658
+
659
+ try {
660
+ const response = await request<{ url?: string }>({
661
+ url: `/file/open/${fileId}`,
662
+ method: 'PUT',
414
663
  });
415
- setLoading(false);
416
- }, 800);
417
- return () => clearTimeout(t);
418
- }, [form]);
419
664
 
420
- // ── File upload handlers ─────────────────────────────────────────────────────
665
+ const url = response?.data?.url;
666
+ if (!url) {
667
+ toast.error(t('toasts.openFileError'));
668
+ return;
669
+ }
670
+
671
+ window.open(url, '_blank', 'noopener,noreferrer');
672
+ } catch {
673
+ toast.error(t('toasts.openFileError'));
674
+ }
675
+ }
421
676
 
422
- function handleFileSelect(
423
- e: React.ChangeEvent<HTMLInputElement>,
424
- setter: (url: string | null) => void
677
+ async function handleFileSelect(
678
+ event: ChangeEvent<HTMLInputElement>,
679
+ setter: (value: string | null) => void,
680
+ type: 'logo' | 'banner'
425
681
  ) {
426
- const file = e.target.files?.[0];
682
+ const file = event.target.files?.[0];
427
683
  if (!file) return;
684
+
428
685
  if (!file.type.startsWith('image/')) {
429
686
  toast.error(t('toasts.onlyImages'));
430
687
  return;
431
688
  }
689
+
432
690
  if (file.size > 5 * 1024 * 1024) {
433
691
  toast.error(t('toasts.maxSize'));
434
692
  return;
435
693
  }
436
- const url = URL.createObjectURL(file);
437
- setter(url);
438
- toast.success(t('toasts.fileSelected', { name: file.name }));
694
+
695
+ const objectUrl = URL.createObjectURL(file);
696
+ setter(objectUrl);
697
+ type === 'logo' ? setUploadingLogo(true) : setUploadingBanner(true);
698
+
699
+ try {
700
+ const formData = new FormData();
701
+ formData.append('file', file);
702
+ formData.append('destination', 'lms/courses');
703
+
704
+ const uploadResponse = await request<{ id: number; filename: string }>({
705
+ url: '/file',
706
+ method: 'POST',
707
+ data: formData,
708
+ headers: {
709
+ 'Content-Type': 'multipart/form-data',
710
+ },
711
+ });
712
+
713
+ const fileId = uploadResponse?.data?.id;
714
+ if (!fileId) {
715
+ throw new Error('invalid file id');
716
+ }
717
+
718
+ await request({
719
+ url: `/lms/courses/${id}`,
720
+ method: 'PATCH',
721
+ data: type === 'logo' ? { logoFileId: fileId } : { bannerFileId: fileId },
722
+ });
723
+
724
+ clearCoursesListCache();
725
+ await refetchCourse();
726
+ toast.success(t('toasts.fileSelected', { name: file.name }));
727
+ } catch {
728
+ toast.error(t('toasts.fileUploadError'));
729
+ } finally {
730
+ type === 'logo' ? setUploadingLogo(false) : setUploadingBanner(false);
731
+ }
732
+ }
733
+
734
+ async function handleCreateCategory(values: Record<string, string>) {
735
+ const name = String(values.name ?? '').trim();
736
+ const slug = slugify(values.slug || values.name || '');
737
+
738
+ if (!name || !slug) {
739
+ toast.error('Informe nome e slug para criar a categoria.');
740
+ return null;
741
+ }
742
+
743
+ const localeCode =
744
+ currentLocaleCode || (locales?.[0] as Locale | undefined)?.code || 'pt-BR';
745
+
746
+ try {
747
+ await request({
748
+ url: '/category',
749
+ method: 'POST',
750
+ data: {
751
+ locale: {
752
+ [localeCode]: {
753
+ name,
754
+ },
755
+ },
756
+ slug,
757
+ category_id: null,
758
+ color: '#000000',
759
+ icon: '',
760
+ status: 'active',
761
+ },
762
+ });
763
+
764
+ const option = { value: slug, label: name };
765
+ setCreatedCategoryOptions((current) => [option, ...current]);
766
+ await refetchCategoryOptions();
767
+ toast.success('Categoria criada com sucesso.');
768
+ return option;
769
+ } catch {
770
+ toast.error('Não foi possível criar a categoria.');
771
+ return null;
772
+ }
439
773
  }
440
774
 
441
- // ── Form submit ──────────────────────────────────────────────────────────────
775
+ async function handleCreateCertificateTemplate(values: Record<string, string>) {
776
+ const name = String(values.name ?? '').trim();
777
+ const description = String(values.description ?? '').trim();
778
+
779
+ if (!name) {
780
+ toast.error('Informe o nome do modelo.');
781
+ return null;
782
+ }
442
783
 
443
- async function onSubmit(data: CursoEditForm) {
784
+ const initialTemplate = createDefaultTemplate();
785
+ initialTemplate.name = name;
786
+
787
+ try {
788
+ const response = await request<ApiCertificateTemplate>({
789
+ url: '/lms/certificates/templates',
790
+ method: 'POST',
791
+ data: {
792
+ name,
793
+ description: description || undefined,
794
+ status: 'draft',
795
+ templateContent: JSON.stringify(initialTemplate),
796
+ },
797
+ });
798
+
799
+ const created = response.data;
800
+ const option = {
801
+ value: created.slug || String(created.id),
802
+ label: created.name,
803
+ description: created.description || null,
804
+ meta: created.status ? `Status: ${created.status}` : null,
805
+ };
806
+
807
+ setCreatedTemplateOptions((current) => [option, ...current]);
808
+ setPersistedCertificateModel(option.value);
809
+ await refetchCertificateTemplates();
810
+ toast.success('Modelo de certificado criado com sucesso.');
811
+ return option;
812
+ } catch {
813
+ toast.error('Não foi possível criar o modelo de certificado.');
814
+ return null;
815
+ }
816
+ }
817
+
818
+ async function onSubmit(data: CourseEditFormValues) {
444
819
  setSaving(true);
445
- await new Promise((r) => setTimeout(r, 800));
446
- setSaving(false);
447
- toast.success(t('toasts.courseUpdated'));
448
- console.log('Dados salvos:', data);
820
+
821
+ try {
822
+ await request({
823
+ url: `/lms/courses/${id}`,
824
+ method: 'PATCH',
825
+ data: {
826
+ code: data.codigo.trim().toUpperCase(),
827
+ slug: data.nomeInterno.trim(),
828
+ title: data.tituloComercial.trim(),
829
+ description: data.descricaoPublica.trim(),
830
+ requirements: data.preRequisitos?.trim() || undefined,
831
+ objectives: data.objetivos?.trim() || undefined,
832
+ targetAudience: data.publicoAlvo?.trim() || undefined,
833
+ level: toApiLevel(data.nivel),
834
+ status: toApiStatus(data.status),
835
+ offeringType: toApiOfferingType(data.tipoOferta),
836
+ categorySlugs: data.categorias,
837
+ primaryColor: data.primaryColor,
838
+ primaryContrastColor: getContrastColor(data.primaryColor),
839
+ secondaryColor: data.secondaryColor,
840
+ secondaryContrastColor: getContrastColor(data.secondaryColor),
841
+ hasCertificate: data.certificado,
842
+ isFeatured: data.destaque,
843
+ isListed: data.listado,
844
+ certificateModel: data.modeloCertificado || undefined,
845
+ instructorIds: (data.instrutores ?? []).map((item) => Number(item)),
846
+ },
847
+ });
848
+
849
+ setPersistedCertificateModel(data.modeloCertificado || '');
850
+ clearCoursesListCache();
851
+ toast.success(t('toasts.courseUpdated'));
852
+ await refetchCourse();
853
+ } finally {
854
+ setSaving(false);
855
+ }
449
856
  }
450
857
 
451
- // ── Delete ───────────────────────────────────────────────────────────────────
858
+ const handleInstructorCreated = async (instructor: {
859
+ id: number;
860
+ personId: number;
861
+ name: string;
862
+ avatarId?: number | null;
863
+ email?: string | null;
864
+ phone?: string | null;
865
+ qualificationSlugs: string[];
866
+ }) => {
867
+ const createdId = String(instructor.id);
868
+ const current = form.getValues('instrutores') ?? [];
869
+
870
+ if (!current.includes(createdId)) {
871
+ form.setValue('instrutores', [...current, createdId], {
872
+ shouldDirty: true,
873
+ shouldTouch: true,
874
+ shouldValidate: true,
875
+ });
876
+ }
877
+
878
+ await refetchInstructorOptions();
879
+ };
452
880
 
453
881
  async function handleDelete() {
454
882
  setDeleting(true);
455
- await new Promise((r) => setTimeout(r, 600));
456
- setDeleting(false);
457
- setDeleteDialogOpen(false);
458
- toast.success(t('toasts.courseDeleted'));
459
- router.push('/cursos');
883
+
884
+ try {
885
+ await request({
886
+ url: `/lms/courses/${id}`,
887
+ method: 'DELETE',
888
+ });
889
+
890
+ clearCoursesListCache();
891
+ setDeleteDialogOpen(false);
892
+ toast.success(t('toasts.courseDeleted'));
893
+ router.push('/lms/courses');
894
+ } finally {
895
+ setDeleting(false);
896
+ }
460
897
  }
461
898
 
462
- // ── Render ───────────────────────────────────────────────────────────────────
899
+ const handleNewStructure = () => {
900
+ router.push(`/lms/courses/${id}/structure`);
901
+ };
902
+
903
+ const handleManageClasses = () => {
904
+ router.push(`/lms/classes?courseId=${id}`);
905
+ };
906
+
907
+ const loading = isLoading || isFetching;
463
908
 
464
909
  return (
465
910
  <Page>
466
911
  <PageHeader
467
- title={` ${MOCK_CURSO.tituloComercial}`}
912
+ title={cursoData?.tituloComercial ?? ''}
468
913
  description={t('pageHeader.description', {
469
- id: id,
470
- creator: 'Ana Paula Mendes',
914
+ id,
915
+ creator: '-',
471
916
  })}
472
917
  breadcrumbs={[
918
+ { label: t('breadcrumbs.home'), href: '/' },
919
+ { label: t('breadcrumbs.courses'), href: '/lms/courses' },
920
+ { label: t('breadcrumbs.editCourse') },
921
+ ]}
922
+ actions={[
473
923
  {
474
- label: t('breadcrumbs.home'),
475
- href: '/',
476
- },
477
- {
478
- label: t('breadcrumbs.courses'),
479
- href: '/courses',
924
+ label: t('actions.goToStructure'),
925
+ onClick: handleNewStructure,
926
+ variant: 'default',
480
927
  },
481
928
  {
482
- label: t('breadcrumbs.editCourse'),
929
+ label: t('actions.manageClasses'),
930
+ onClick: handleManageClasses,
931
+ variant: 'outline',
483
932
  },
484
933
  ]}
485
- actions={
486
- <div className="flex items-center gap-2">
487
- <Button
488
- variant="outline"
489
- className="gap-2 shadow-none"
490
- onClick={() => router.push('/lms/classes')}
491
- >
492
- <Users className="size-4" />
493
- {t('actions.manageClasses')}
494
- </Button>
495
- <Button
496
- variant="outline"
497
- className="gap-2 shadow-none"
498
- onClick={() => router.push('/lms/reports')}
499
- >
500
- <BarChart3 className="size-4" />
501
- {t('actions.viewReports')}
502
- </Button>
503
- <Button
504
- className="gap-2"
505
- onClick={() => router.push(`/lms/courses/${id}/structure`)}
506
- >
507
- <Layers className="size-4" />
508
- {t('actions.goToStructure')}
509
- </Button>
510
- </div>
511
- }
512
934
  />
513
935
 
514
- {/* ── Main ───────────────────────────────────────────────────────────── */}
515
- <div>
516
- {loading ? (
517
- <LoadingSkeleton />
518
- ) : (
519
- <motion.div initial="hidden" animate="show" variants={stagger}>
520
- {/* Breadcrumb */}
521
- {/*<motion.div variants={fadeUp} className="mb-6">
522
- <Link
523
- href="/cursos"
524
- className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
525
- >
526
- <ArrowLeft className="size-4" />
527
- Voltar para Cursos
528
- </Link>
529
- </motion.div>*/}
530
-
531
- {/* Page Header */}
532
- <motion.div
533
- variants={fadeUp}
534
- className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
535
- >
536
- <div className="flex-1">
537
- <div className="mb-2 flex flex-wrap items-center gap-2">
538
- <Badge variant="outline" className="font-mono text-xs">
539
- {MOCK_CURSO.codigo}
540
- </Badge>
541
- <Badge
542
- variant={
543
- STATUS_MAP[MOCK_CURSO.status]?.variant || 'default'
544
- }
545
- >
546
- {STATUS_MAP[MOCK_CURSO.status]?.label || MOCK_CURSO.status}
547
- </Badge>
548
- {MOCK_CURSO.destaque && (
549
- <Badge
550
- variant="secondary"
551
- className="border-amber-200 bg-amber-50 text-amber-700"
552
- >
553
- {t('badges.featured')}
554
- </Badge>
555
- )}
556
- </div>
936
+ {loading ? (
937
+ <LoadingSkeleton />
938
+ ) : !cursoData ? (
939
+ <EmptyState
940
+ icon={<BookOpen className="size-12 text-muted-foreground/40" />}
941
+ title="Curso não encontrado"
942
+ description={`Não foi possível localizar o curso com id ${id}.`}
943
+ actionLabel="Voltar para cursos"
944
+ onAction={() => router.push('/lms/courses')}
945
+ className="py-20"
946
+ />
947
+ ) : (
948
+ <Form {...form}>
949
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
950
+ <CourseSummaryCard course={cursoData} />
951
+
952
+ <div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
953
+ <div className="space-y-6">
954
+ <CourseMainInfoCard form={form} t={t} />
955
+ <CourseClassificationCard
956
+ form={form}
957
+ t={t}
958
+ levels={NIVEIS}
959
+ statuses={STATUS_OPTIONS}
960
+ offeringTypes={OFFERING_TYPE_OPTIONS}
961
+ />
962
+ <CourseRelationsCard
963
+ form={form}
964
+ t={t}
965
+ categoryOptions={categoryOptions}
966
+ instructorOptions={instructorOptions}
967
+ onCreateCategory={handleCreateCategory}
968
+ onCreateInstructor={() => setInstructorSheetOpen(true)}
969
+ />
970
+ <CourseContentCard form={form} />
557
971
  </div>
558
- </motion.div>
559
972
 
560
- {/* ── KPI Cards ──────────────────────────────────────────────────── */}
561
- <motion.div
562
- variants={fadeUp}
563
- className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-5"
564
- >
565
- {[
566
- {
567
- label: 'Total Alunos',
568
- value: MOCK_CURSO.totalAlunos.toLocaleString('pt-BR'),
569
- icon: Users,
570
- color: 'bg-foreground text-background',
571
- },
572
- {
573
- label: 'Conclusao Media',
574
- value: `${MOCK_CURSO.conclusaoMedia}%`,
575
- icon: Percent,
576
- color: 'bg-emerald-100 text-emerald-700',
577
- },
578
- {
579
- label: 'Total de Aulas',
580
- value: MOCK_CURSO.totalAulas.toString(),
581
- icon: Video,
582
- color: 'bg-blue-100 text-blue-700',
583
- },
584
- {
585
- label: 'Sessoes',
586
- value: MOCK_CURSO.totalSessoes.toString(),
587
- icon: CalendarDays,
588
- color: 'bg-violet-100 text-violet-700',
589
- },
590
- {
591
- label: 'Certificados',
592
- value:
593
- MOCK_CURSO.certificadosEmitidos.toLocaleString('pt-BR'),
594
- icon: Award,
595
- color: 'bg-amber-100 text-amber-700',
596
- },
597
- ].map((kpi) => (
598
- <motion.div
599
- key={kpi.label}
600
- whileHover={{ y: -2 }}
601
- transition={{ duration: 0.2 }}
602
- >
603
- <Card className="transition-shadow hover:shadow-md">
604
- <CardContent className="flex items-center gap-3 p-4">
605
- <div
606
- className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.color}`}
607
- >
608
- <kpi.icon className="size-5" />
609
- </div>
610
- <div className="min-w-0">
611
- <p className="truncate text-xs text-muted-foreground">
612
- {kpi.label}
613
- </p>
614
- <p className="text-xl font-bold tabular-nums">
615
- {kpi.value}
616
- </p>
617
- </div>
618
- </CardContent>
619
- </Card>
620
- </motion.div>
621
- ))}
622
- </motion.div>
623
-
624
- {/* ── Progress Chart ──────────────────────────────────────────────── */}
625
- <motion.div variants={fadeUp} className="mb-8">
626
- <Card>
627
- <CardHeader>
628
- <div className="flex items-center gap-2">
629
- <TrendingUp className="size-4 text-muted-foreground" />
630
- <CardTitle className="text-sm font-semibold">
631
- {t('chart.title')}
632
- </CardTitle>
633
- </div>
634
- <CardDescription>{t('chart.description')}</CardDescription>
635
- </CardHeader>
636
- <CardContent>
637
- <div className="h-64">
638
- <ResponsiveContainer width="100%" height="100%">
639
- <BarChart
640
- data={progressData}
641
- layout="vertical"
642
- margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
643
- >
644
- <CartesianGrid
645
- strokeDasharray="3 3"
646
- horizontal={false}
647
- stroke="oklch(0.922 0 0)"
648
- />
649
- <XAxis
650
- type="number"
651
- domain={[0, 100]}
652
- tick={{ fontSize: 12, fill: 'oklch(0.556 0 0)' }}
653
- tickFormatter={(v) => `${v}%`}
654
- />
655
- <YAxis
656
- dataKey="modulo"
657
- type="category"
658
- tick={{ fontSize: 12, fill: 'oklch(0.556 0 0)' }}
659
- width={50}
660
- />
661
- <Tooltip
662
- formatter={(value: number) => [
663
- `${value}%`,
664
- t('chart.progress'),
665
- ]}
666
- contentStyle={{
667
- borderRadius: 8,
668
- border: '1px solid oklch(0.922 0 0)',
669
- boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
670
- fontSize: 12,
671
- }}
672
- />
673
- <defs>
674
- <linearGradient
675
- id="colorProgresso"
676
- x1="0"
677
- y1="0"
678
- x2="1"
679
- y2="0"
680
- >
681
- <stop offset="0%" stopColor="#3b82f6" />
682
- <stop offset="100%" stopColor="#60a5fa" />
683
- </linearGradient>
684
- </defs>
685
- <Bar
686
- dataKey="progresso"
687
- fill="url(#colorProgresso)"
688
- radius={[0, 6, 6, 0]}
689
- maxBarSize={32}
690
- />
691
- </BarChart>
692
- </ResponsiveContainer>
693
- </div>
694
- </CardContent>
695
- </Card>
696
- </motion.div>
697
-
698
- {/* ── Form ───────────────────────────────────────────────────────── */}
699
- <motion.div variants={fadeUp}>
700
- <Card>
701
- <CardHeader>
702
- <CardTitle className="text-base font-semibold">
703
- {t('form.title')}
704
- </CardTitle>
705
- <CardDescription>{t('form.description')}</CardDescription>
706
- </CardHeader>
707
- <CardContent>
708
- <form
709
- onSubmit={form.handleSubmit(onSubmit)}
710
- className="flex flex-col gap-6"
711
- >
712
- {/* ── Row: Codigo + Nome Interno ────────────────────────── */}
713
- <div className="grid gap-4 sm:grid-cols-2">
714
- <Field data-invalid={!!form.formState.errors.codigo}>
715
- <FieldLabel htmlFor="codigo">
716
- <Hash className="size-3.5 text-muted-foreground" />
717
- {t('form.fields.code.label')}
718
- </FieldLabel>
719
- <Input
720
- id="codigo"
721
- placeholder={t('form.fields.code.placeholder')}
722
- className="uppercase"
723
- {...form.register('codigo')}
724
- />
725
- {form.formState.errors.codigo && (
726
- <FieldError>
727
- {form.formState.errors.codigo.message}
728
- </FieldError>
729
- )}
730
- <FieldDescription>
731
- {t('form.fields.code.description')}
732
- </FieldDescription>
733
- </Field>
734
-
735
- <Field data-invalid={!!form.formState.errors.nomeInterno}>
736
- <FieldLabel htmlFor="nomeInterno">
737
- {t('form.fields.internalName.label')}
738
- </FieldLabel>
739
- <Input
740
- id="nomeInterno"
741
- placeholder={t(
742
- 'form.fields.internalName.placeholder'
743
- )}
744
- {...form.register('nomeInterno')}
745
- />
746
- {form.formState.errors.nomeInterno && (
747
- <FieldError>
748
- {form.formState.errors.nomeInterno.message}
749
- </FieldError>
750
- )}
751
- <FieldDescription>
752
- {t('form.fields.internalName.description')}
753
- </FieldDescription>
754
- </Field>
755
- </div>
756
-
757
- {/* ── Titulo Comercial ──────────────────────────────────── */}
758
- <Field
759
- data-invalid={!!form.formState.errors.tituloComercial}
760
- >
761
- <FieldLabel htmlFor="tituloComercial">
762
- {t('form.fields.title.label')}
763
- </FieldLabel>
764
- <Input
765
- id="tituloComercial"
766
- placeholder={t('form.fields.title.placeholder')}
767
- {...form.register('tituloComercial')}
768
- />
769
- {form.formState.errors.tituloComercial && (
770
- <FieldError>
771
- {form.formState.errors.tituloComercial.message}
772
- </FieldError>
773
- )}
774
- </Field>
775
-
776
- {/* ── Descricao Publica ─────────────────────────────────── */}
777
- <Field
778
- data-invalid={!!form.formState.errors.descricaoPublica}
779
- >
780
- <FieldLabel htmlFor="descricaoPublica">
781
- {t('form.fields.description.label')}
782
- </FieldLabel>
783
- <Textarea
784
- id="descricaoPublica"
785
- placeholder={t('form.fields.description.placeholder')}
786
- rows={4}
787
- {...form.register('descricaoPublica')}
788
- />
789
- {form.formState.errors.descricaoPublica && (
790
- <FieldError>
791
- {form.formState.errors.descricaoPublica.message}
792
- </FieldError>
793
- )}
794
- <FieldDescription>
795
- {t('form.fields.description.description')}
796
- </FieldDescription>
797
- </Field>
798
-
799
- {/* ── Row: Nivel + Status ───────────────────────────────── */}
800
- <div className="grid gap-4 sm:grid-cols-2">
801
- <Field data-invalid={!!form.formState.errors.nivel}>
802
- <FieldLabel>{t('form.fields.level.label')}</FieldLabel>
803
- <Controller
804
- control={form.control}
805
- name="nivel"
806
- render={({ field }) => (
807
- <Select
808
- value={field.value}
809
- onValueChange={field.onChange}
810
- >
811
- <SelectTrigger>
812
- <SelectValue
813
- placeholder={t(
814
- 'form.fields.level.placeholder'
815
- )}
816
- />
817
- </SelectTrigger>
818
- <SelectContent>
819
- {NIVEIS.map((n) => (
820
- <SelectItem key={n.value} value={n.value}>
821
- {n.label}
822
- </SelectItem>
823
- ))}
824
- </SelectContent>
825
- </Select>
826
- )}
827
- />
828
- {form.formState.errors.nivel && (
829
- <FieldError>
830
- {form.formState.errors.nivel.message}
831
- </FieldError>
832
- )}
833
- </Field>
834
-
835
- <Field data-invalid={!!form.formState.errors.status}>
836
- <FieldLabel>{t('form.fields.status.label')}</FieldLabel>
837
- <Controller
838
- control={form.control}
839
- name="status"
840
- render={({ field }) => (
841
- <Select
842
- value={field.value}
843
- onValueChange={field.onChange}
844
- >
845
- <SelectTrigger>
846
- <SelectValue
847
- placeholder={t(
848
- 'form.fields.status.placeholder'
849
- )}
850
- />
851
- </SelectTrigger>
852
- <SelectContent>
853
- {STATUS_OPTIONS.map((s) => (
854
- <SelectItem key={s.value} value={s.value}>
855
- {s.label}
856
- </SelectItem>
857
- ))}
858
- </SelectContent>
859
- </Select>
860
- )}
861
- />
862
- {form.formState.errors.status && (
863
- <FieldError>
864
- {form.formState.errors.status.message}
865
- </FieldError>
866
- )}
867
- </Field>
868
- </div>
869
-
870
- <Separator />
871
-
872
- {/* ── Categorias ────────────────────────────────────────── */}
873
- <Field data-invalid={!!form.formState.errors.categorias}>
874
- <FieldLabel>
875
- {t('form.fields.categories.label')}
876
- </FieldLabel>
877
- <FieldDescription>
878
- {t('form.fields.categories.description')}
879
- </FieldDescription>
880
- <Controller
881
- control={form.control}
882
- name="categorias"
883
- render={({ field }) => (
884
- <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
885
- {CATEGORIAS.map((cat) => {
886
- const checked = field.value.includes(cat);
887
- return (
888
- <label
889
- key={cat}
890
- className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-checked:border-foreground has-checked:bg-muted/50"
891
- >
892
- <Checkbox
893
- checked={checked}
894
- onCheckedChange={(isChecked) => {
895
- if (isChecked) {
896
- field.onChange([...field.value, cat]);
897
- } else {
898
- field.onChange(
899
- field.value.filter(
900
- (v: string) => v !== cat
901
- )
902
- );
903
- }
904
- }}
905
- />
906
- {cat}
907
- </label>
908
- );
909
- })}
910
- </div>
911
- )}
912
- />
913
- {form.formState.errors.categorias && (
914
- <FieldError>
915
- {form.formState.errors.categorias.message}
916
- </FieldError>
917
- )}
918
- </Field>
919
-
920
- <Separator />
921
-
922
- {/* ── Instrutores ───────────────────────────────────────── */}
923
- <Field>
924
- <FieldLabel>
925
- {t('form.fields.instructors.label')}
926
- </FieldLabel>
927
- <FieldDescription>
928
- {t('form.fields.instructors.description')}
929
- </FieldDescription>
930
- <Controller
931
- control={form.control}
932
- name="instrutores"
933
- render={({ field }) => (
934
- <div className="flex flex-col gap-2">
935
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
936
- {INSTRUTORES.map((inst) => {
937
- const checked = field.value.includes(inst.id);
938
- return (
939
- <label
940
- key={inst.id}
941
- className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-checked:border-foreground has-checked:bg-muted/50"
942
- >
943
- <Checkbox
944
- checked={checked}
945
- onCheckedChange={(isChecked) => {
946
- if (isChecked) {
947
- field.onChange([
948
- ...field.value,
949
- inst.id,
950
- ]);
951
- } else {
952
- field.onChange(
953
- field.value.filter(
954
- (v: string) => v !== inst.id
955
- )
956
- );
957
- }
958
- }}
959
- />
960
- <UserCheck className="size-3.5 text-muted-foreground" />
961
- {inst.nome}
962
- </label>
963
- );
964
- })}
965
- </div>
966
- {field.value.length > 0 && (
967
- <p className="text-xs text-muted-foreground">
968
- {t('form.fields.instructors.selected', {
969
- count: field.value.length,
970
- })}
971
- </p>
972
- )}
973
- </div>
974
- )}
975
- />
976
- </Field>
977
-
978
- <Separator />
979
-
980
- {/* ── Pre-requisitos ────────────────────────────────────── */}
981
- <Field>
982
- <FieldLabel htmlFor="preRequisitos">
983
- {t('form.fields.prerequisites.label')}
984
- </FieldLabel>
985
- <Textarea
986
- id="preRequisitos"
987
- placeholder={t('form.fields.prerequisites.placeholder')}
988
- rows={2}
989
- {...form.register('preRequisitos')}
990
- />
991
- <FieldDescription>
992
- {t('form.fields.prerequisites.description')}
993
- </FieldDescription>
994
- </Field>
995
-
996
- <Separator />
997
-
998
- {/* ── Upload Logo + Banner ─────────────────────────────── */}
999
- <div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.25fr)]">
1000
- <MediaUploadField
1001
- label={t('form.fields.logo.label')}
1002
- description={t('form.fields.logo.description')}
1003
- uploadLabel={t('form.fields.logo.clickToUpload')}
1004
- changeLabel={t('form.fields.logo.change')}
1005
- removeLabel={t('form.fields.logo.remove')}
1006
- emptyTitle={t('form.fields.logo.emptyTitle')}
1007
- preview={logoPreview}
1008
- previewAlt={t('form.fields.logo.previewAlt')}
1009
- inputRef={logoInputRef}
1010
- onInputChange={(e) =>
1011
- handleFileSelect(e, setLogoPreview)
973
+ <div className="space-y-6">
974
+ <CourseMediaCard
975
+ logoPreview={logoPreview}
976
+ bannerPreview={bannerPreview}
977
+ uploadingLogo={uploadingLogo}
978
+ uploadingBanner={uploadingBanner}
979
+ onLogoSelect={(event) =>
980
+ handleFileSelect(event, setLogoPreview, 'logo')
981
+ }
982
+ onBannerSelect={(event) =>
983
+ handleFileSelect(event, setBannerPreview, 'banner')
984
+ }
985
+ logoFile={
986
+ apiCourse?.logoFileId
987
+ ? {
988
+ id: apiCourse.logoFileId,
989
+ name: apiCourse.logoFilename || `#${apiCourse.logoFileId}`,
1012
990
  }
1013
- onClear={() => setLogoPreview(null)}
1014
- icon={ImageIcon}
1015
- aspectClassName="aspect-square"
1016
- compactPreview
1017
- />
1018
-
1019
- <MediaUploadField
1020
- label={t('form.fields.banner.label')}
1021
- description={t('form.fields.banner.description')}
1022
- uploadLabel={t('form.fields.banner.clickToUpload')}
1023
- changeLabel={t('form.fields.banner.change')}
1024
- removeLabel={t('form.fields.banner.remove')}
1025
- emptyTitle={t('form.fields.banner.emptyTitle')}
1026
- preview={bannerPreview}
1027
- previewAlt={t('form.fields.banner.previewAlt')}
1028
- inputRef={bannerInputRef}
1029
- onInputChange={(e) =>
1030
- handleFileSelect(e, setBannerPreview)
991
+ : undefined
992
+ }
993
+ bannerFile={
994
+ apiCourse?.bannerFileId
995
+ ? {
996
+ id: apiCourse.bannerFileId,
997
+ name:
998
+ apiCourse.bannerFilename || `#${apiCourse.bannerFileId}`,
1031
999
  }
1032
- onClear={() => setBannerPreview(null)}
1033
- icon={ImageIcon}
1034
- aspectClassName="aspect-[16/9]"
1035
- />
1036
- </div>
1037
-
1038
- <Separator />
1039
-
1040
- {/* ── Modelo de Certificado ─────────────────────────────── */}
1041
- <Field>
1042
- <FieldLabel>
1043
- {t('form.fields.certificateModel.label')}
1044
- </FieldLabel>
1045
- <FieldDescription>
1046
- {t('form.fields.certificateModel.description')}
1047
- </FieldDescription>
1048
- <Controller
1049
- control={form.control}
1050
- name="modeloCertificado"
1051
- render={({ field }) => (
1052
- <Select
1053
- value={field.value}
1054
- onValueChange={field.onChange}
1055
- >
1056
- <SelectTrigger>
1057
- <SelectValue
1058
- placeholder={t(
1059
- 'form.fields.certificateModel.placeholder'
1060
- )}
1061
- />
1062
- </SelectTrigger>
1063
- <SelectContent>
1064
- {MODELOS_CERTIFICADO.map((m) => (
1065
- <SelectItem key={m.value} value={m.value}>
1066
- <div className="flex items-center gap-2">
1067
- <Award className="size-3.5 text-muted-foreground" />
1068
- {m.label}
1069
- </div>
1070
- </SelectItem>
1071
- ))}
1072
- </SelectContent>
1073
- </Select>
1074
- )}
1075
- />
1076
- </Field>
1077
-
1078
- <Separator />
1079
-
1080
- {/* ── Flags ─────────────────────────────────────────────── */}
1081
- <div className="flex flex-col gap-3">
1082
- <p className="text-sm font-medium">
1083
- {t('form.flags.title')}
1084
- </p>
1085
- <div className="grid gap-3 sm:grid-cols-3">
1086
- <Controller
1087
- control={form.control}
1088
- name="destaque"
1089
- render={({ field }) => (
1090
- <div className="flex items-center justify-between rounded-md border px-4 py-3">
1091
- <div className="flex flex-col gap-0.5">
1092
- <span className="text-sm font-medium">
1093
- {t('form.flags.featured.label')}
1094
- </span>
1095
- <span className="text-xs text-muted-foreground">
1096
- {t('form.flags.featured.description')}
1097
- </span>
1098
- </div>
1099
- <Switch
1100
- checked={field.value}
1101
- onCheckedChange={field.onChange}
1102
- />
1103
- </div>
1104
- )}
1105
- />
1106
- <Controller
1107
- control={form.control}
1108
- name="certificado"
1109
- render={({ field }) => (
1110
- <div className="flex items-center justify-between rounded-md border px-4 py-3">
1111
- <div className="flex flex-col gap-0.5">
1112
- <span className="text-sm font-medium">
1113
- {t('form.flags.certificate.label')}
1114
- </span>
1115
- <span className="text-xs text-muted-foreground">
1116
- {t('form.flags.certificate.description')}
1117
- </span>
1118
- </div>
1119
- <Switch
1120
- checked={field.value}
1121
- onCheckedChange={field.onChange}
1122
- />
1123
- </div>
1124
- )}
1125
- />
1126
- <Controller
1127
- control={form.control}
1128
- name="listado"
1129
- render={({ field }) => (
1130
- <div className="flex items-center justify-between rounded-md border px-4 py-3">
1131
- <div className="flex flex-col gap-0.5">
1132
- <span className="text-sm font-medium">
1133
- {t('form.flags.listed.label')}
1134
- </span>
1135
- <span className="text-xs text-muted-foreground">
1136
- {t('form.flags.listed.description')}
1137
- </span>
1138
- </div>
1139
- <Switch
1140
- checked={field.value}
1141
- onCheckedChange={field.onChange}
1142
- />
1143
- </div>
1144
- )}
1145
- />
1146
- </div>
1147
- </div>
1148
-
1149
- <Separator />
1150
-
1151
- {/* ── Form Actions ──────────────────────────────────────── */}
1152
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1153
- <Button
1154
- type="button"
1155
- variant="outline"
1156
- className="gap-2 text-destructive hover:text-destructive"
1157
- onClick={() => setDeleteDialogOpen(true)}
1158
- >
1159
- <Trash2 className="size-4" />
1160
- {t('form.actions.deleteCourse')}
1161
- </Button>
1162
- <div className="flex items-center gap-2">
1163
- <Button
1164
- type="button"
1165
- variant="outline"
1166
- onClick={() => router.push('/cursos')}
1167
- >
1168
- {t('form.actions.cancel')}
1169
- </Button>
1170
- <Button
1171
- type="submit"
1172
- disabled={saving}
1173
- className="gap-2"
1174
- >
1175
- {saving ? (
1176
- <Loader2 className="size-4 animate-spin" />
1177
- ) : (
1178
- <Save className="size-4" />
1179
- )}
1180
- {t('form.actions.saveChanges')}
1181
- </Button>
1182
- </div>
1183
- </div>
1184
- </form>
1185
- </CardContent>
1186
- </Card>
1187
- </motion.div>
1188
- </motion.div>
1189
- )}
1190
- </div>
1000
+ : undefined
1001
+ }
1002
+ onOpenLogoFile={() => openUploadedFile(apiCourse?.logoFileId)}
1003
+ onOpenBannerFile={() => openUploadedFile(apiCourse?.bannerFileId)}
1004
+ t={t}
1005
+ />
1006
+
1007
+ <CourseCertificateCard
1008
+ form={form}
1009
+ t={t}
1010
+ options={certificateOptions}
1011
+ onCreateTemplate={handleCreateCertificateTemplate}
1012
+ />
1013
+
1014
+ <CourseFlagsCard form={form} t={t} />
1015
+
1016
+ <CourseDangerZoneCard
1017
+ t={t}
1018
+ onDelete={() => setDeleteDialogOpen(true)}
1019
+ />
1020
+ </div>
1021
+ </div>
1022
+
1023
+ <div className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/80 p-4 backdrop-blur sm:flex-row sm:items-center sm:justify-between">
1024
+ <p className="text-sm text-muted-foreground">
1025
+ Revise os vínculos e a mídia antes de salvar para evitar retrabalho.
1026
+ </p>
1027
+
1028
+ <div className="flex items-center gap-2">
1029
+ <Button
1030
+ type="button"
1031
+ variant="outline"
1032
+ onClick={() => router.push('/lms/courses')}
1033
+ >
1034
+ {t('form.actions.cancel')}
1035
+ </Button>
1036
+ <Button type="submit" disabled={saving} className="gap-2">
1037
+ {saving ? (
1038
+ <Loader2 className="h-4 w-4 animate-spin" />
1039
+ ) : (
1040
+ <Save className="h-4 w-4" />
1041
+ )}
1042
+ {t('form.actions.saveChanges')}
1043
+ </Button>
1044
+ </div>
1045
+ </div>
1046
+ </form>
1047
+ </Form>
1048
+ )}
1049
+
1050
+ <CreateLmsPersonSheet
1051
+ open={instructorSheetOpen}
1052
+ onOpenChange={setInstructorSheetOpen}
1053
+ onCreated={handleInstructorCreated}
1054
+ title="Cadastrar instrutor"
1055
+ description="Cadastre um novo instrutor e adicione-o ao curso."
1056
+ submitLabel="Cadastrar instrutor"
1057
+ successMessage="Instrutor cadastrado com sucesso."
1058
+ errorMessage="Não foi possível cadastrar o instrutor."
1059
+ defaultQualificationSlugs={['course-lessons']}
1060
+ />
1191
1061
 
1192
- {/* ── Dialog: Confirmar Exclusao ────────────────────────────────────── */}
1193
1062
  <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1194
1063
  <DialogContent className="max-w-3xl">
1195
1064
  <DialogHeader>
@@ -1202,29 +1071,26 @@ export default function CursoEditPage({
1202
1071
  <p>
1203
1072
  {t('deleteDialog.description')}{' '}
1204
1073
  <strong className="text-foreground">
1205
- {MOCK_CURSO.tituloComercial}
1074
+ {cursoData?.tituloComercial}
1206
1075
  </strong>
1207
1076
  ?
1208
1077
  </p>
1209
- {MOCK_CURSO.totalAlunos > 0 && (
1210
- <div className="flex items-center gap-1.5 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
1078
+ {(cursoData?.totalAlunos ?? 0) > 0 ? (
1079
+ <div className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
1211
1080
  <AlertTriangle className="size-3.5 shrink-0" />
1212
1081
  <span>
1213
1082
  {t('deleteDialog.warning', {
1214
- students: MOCK_CURSO.totalAlunos,
1215
- certificates: MOCK_CURSO.certificadosEmitidos,
1083
+ students: cursoData?.totalAlunos ?? 0,
1084
+ certificates: cursoData?.certificadosEmitidos ?? 0,
1216
1085
  })}
1217
1086
  </span>
1218
1087
  </div>
1219
- )}
1088
+ ) : null}
1220
1089
  </div>
1221
1090
  </DialogDescription>
1222
1091
  </DialogHeader>
1223
1092
  <DialogFooter className="gap-2">
1224
- <Button
1225
- variant="outline"
1226
- onClick={() => setDeleteDialogOpen(false)}
1227
- >
1093
+ <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
1228
1094
  {t('deleteDialog.actions.cancel')}
1229
1095
  </Button>
1230
1096
  <Button
@@ -1233,11 +1099,7 @@ export default function CursoEditPage({
1233
1099
  disabled={deleting}
1234
1100
  className="gap-2"
1235
1101
  >
1236
- {deleting ? (
1237
- <Loader2 className="size-4 animate-spin" />
1238
- ) : (
1239
- <Trash2 className="size-4" />
1240
- )}
1102
+ {deleting ? <Loader2 className="size-4 animate-spin" /> : null}
1241
1103
  {t('deleteDialog.actions.delete')}
1242
1104
  </Button>
1243
1105
  </DialogFooter>
@@ -1246,35 +1108,3 @@ export default function CursoEditPage({
1246
1108
  </Page>
1247
1109
  );
1248
1110
  }
1249
-
1250
- // ── Loading Skeleton ──────────────────────────────────────────────────────────
1251
-
1252
- function LoadingSkeleton() {
1253
- return (
1254
- <div className="flex flex-col gap-6">
1255
- <Skeleton className="h-4 w-32" />
1256
- <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
1257
- <div className="flex flex-col gap-2">
1258
- <div className="flex items-center gap-2">
1259
- <Skeleton className="h-5 w-24 rounded-full" />
1260
- <Skeleton className="h-5 w-16 rounded-full" />
1261
- </div>
1262
- <Skeleton className="h-8 w-64" />
1263
- <Skeleton className="h-4 w-40" />
1264
- </div>
1265
- <div className="flex items-center gap-2">
1266
- <Skeleton className="h-9 w-36 rounded-md" />
1267
- <Skeleton className="h-9 w-36 rounded-md" />
1268
- <Skeleton className="h-9 w-32 rounded-md" />
1269
- </div>
1270
- </div>
1271
- <div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
1272
- {Array.from({ length: 5 }).map((_, i) => (
1273
- <Skeleton key={i} className="h-20 rounded-xl" />
1274
- ))}
1275
- </div>
1276
- <Skeleton className="h-80 rounded-xl" />
1277
- <Skeleton className="h-[600px] rounded-xl" />
1278
- </div>
1279
- );
1280
- }