@hed-hog/lms 0.0.304 → 0.0.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. package/README.md +413 -401
  2. package/dist/certificate/certificate.controller.d.ts +90 -0
  3. package/dist/certificate/certificate.controller.d.ts.map +1 -0
  4. package/dist/certificate/certificate.controller.js +121 -0
  5. package/dist/certificate/certificate.controller.js.map +1 -0
  6. package/dist/certificate/certificate.module.d.ts +3 -0
  7. package/dist/certificate/certificate.module.d.ts.map +1 -0
  8. package/dist/certificate/certificate.module.js +26 -0
  9. package/dist/certificate/certificate.module.js.map +1 -0
  10. package/dist/certificate/certificate.service.d.ts +115 -0
  11. package/dist/certificate/certificate.service.d.ts.map +1 -0
  12. package/dist/certificate/certificate.service.js +343 -0
  13. package/dist/certificate/certificate.service.js.map +1 -0
  14. package/dist/certificate/dto/create-certificate-template.dto.d.ts +8 -0
  15. package/dist/certificate/dto/create-certificate-template.dto.d.ts.map +1 -0
  16. package/dist/certificate/dto/create-certificate-template.dto.js +44 -0
  17. package/dist/certificate/dto/create-certificate-template.dto.js.map +1 -0
  18. package/dist/certificate/dto/update-certificate-template.dto.d.ts +6 -0
  19. package/dist/certificate/dto/update-certificate-template.dto.d.ts.map +1 -0
  20. package/dist/certificate/dto/update-certificate-template.dto.js +9 -0
  21. package/dist/certificate/dto/update-certificate-template.dto.js.map +1 -0
  22. package/dist/class-group/class-group.controller.d.ts +305 -0
  23. package/dist/class-group/class-group.controller.d.ts.map +1 -0
  24. package/dist/class-group/class-group.controller.js +257 -0
  25. package/dist/class-group/class-group.controller.js.map +1 -0
  26. package/dist/class-group/class-group.module.d.ts +3 -0
  27. package/dist/class-group/class-group.module.d.ts.map +1 -0
  28. package/dist/class-group/class-group.module.js +25 -0
  29. package/dist/class-group/class-group.module.js.map +1 -0
  30. package/dist/class-group/class-group.service.d.ts +354 -0
  31. package/dist/class-group/class-group.service.d.ts.map +1 -0
  32. package/dist/class-group/class-group.service.js +1356 -0
  33. package/dist/class-group/class-group.service.js.map +1 -0
  34. package/dist/class-group/dto/create-class-group.dto.d.ts +33 -0
  35. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -0
  36. package/dist/class-group/dto/create-class-group.dto.js +165 -0
  37. package/dist/class-group/dto/create-class-group.dto.js.map +1 -0
  38. package/dist/class-group/dto/create-session.dto.d.ts +22 -0
  39. package/dist/class-group/dto/create-session.dto.d.ts.map +1 -0
  40. package/dist/class-group/dto/create-session.dto.js +117 -0
  41. package/dist/class-group/dto/create-session.dto.js.map +1 -0
  42. package/dist/class-group/dto/enrollment.dto.d.ts +22 -0
  43. package/dist/class-group/dto/enrollment.dto.d.ts.map +1 -0
  44. package/dist/class-group/dto/enrollment.dto.js +89 -0
  45. package/dist/class-group/dto/enrollment.dto.js.map +1 -0
  46. package/dist/class-group/dto/update-class-group.dto.d.ts +6 -0
  47. package/dist/class-group/dto/update-class-group.dto.d.ts.map +1 -0
  48. package/dist/class-group/dto/update-class-group.dto.js +9 -0
  49. package/dist/class-group/dto/update-class-group.dto.js.map +1 -0
  50. package/dist/class-group/dto/update-session.dto.d.ts +7 -0
  51. package/dist/class-group/dto/update-session.dto.d.ts.map +1 -0
  52. package/dist/class-group/dto/update-session.dto.js +24 -0
  53. package/dist/class-group/dto/update-session.dto.js.map +1 -0
  54. package/dist/course/course-structure.controller.d.ts +127 -0
  55. package/dist/course/course-structure.controller.d.ts.map +1 -0
  56. package/dist/course/course-structure.controller.js +115 -0
  57. package/dist/course/course-structure.controller.js.map +1 -0
  58. package/dist/course/course-structure.service.d.ts +142 -0
  59. package/dist/course/course-structure.service.d.ts.map +1 -0
  60. package/dist/course/course-structure.service.js +445 -0
  61. package/dist/course/course-structure.service.js.map +1 -0
  62. package/dist/course/course.controller.d.ts +195 -0
  63. package/dist/course/course.controller.d.ts.map +1 -0
  64. package/dist/course/course.controller.js +104 -0
  65. package/dist/course/course.controller.js.map +1 -0
  66. package/dist/course/course.module.d.ts +3 -0
  67. package/dist/course/course.module.d.ts.map +1 -0
  68. package/dist/course/course.module.js +28 -0
  69. package/dist/course/course.module.js.map +1 -0
  70. package/dist/course/course.service.d.ts +215 -0
  71. package/dist/course/course.service.d.ts.map +1 -0
  72. package/dist/course/course.service.js +743 -0
  73. package/dist/course/course.service.js.map +1 -0
  74. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +24 -0
  75. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -0
  76. package/dist/course/dto/create-course-structure-lesson.dto.js +118 -0
  77. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -0
  78. package/dist/course/dto/create-course-structure-session.dto.d.ts +7 -0
  79. package/dist/course/dto/create-course-structure-session.dto.d.ts.map +1 -0
  80. package/dist/course/dto/create-course-structure-session.dto.js +40 -0
  81. package/dist/course/dto/create-course-structure-session.dto.js.map +1 -0
  82. package/dist/course/dto/create-course.dto.d.ts +26 -0
  83. package/dist/course/dto/create-course.dto.d.ts.map +1 -0
  84. package/dist/course/dto/create-course.dto.js +138 -0
  85. package/dist/course/dto/create-course.dto.js.map +1 -0
  86. package/dist/course/dto/update-course-structure-lesson.dto.d.ts +6 -0
  87. package/dist/course/dto/update-course-structure-lesson.dto.d.ts.map +1 -0
  88. package/dist/course/dto/update-course-structure-lesson.dto.js +9 -0
  89. package/dist/course/dto/update-course-structure-lesson.dto.js.map +1 -0
  90. package/dist/course/dto/update-course-structure-session.dto.d.ts +6 -0
  91. package/dist/course/dto/update-course-structure-session.dto.d.ts.map +1 -0
  92. package/dist/course/dto/update-course-structure-session.dto.js +9 -0
  93. package/dist/course/dto/update-course-structure-session.dto.js.map +1 -0
  94. package/dist/course/dto/update-course.dto.d.ts +6 -0
  95. package/dist/course/dto/update-course.dto.d.ts.map +1 -0
  96. package/dist/course/dto/update-course.dto.js +9 -0
  97. package/dist/course/dto/update-course.dto.js.map +1 -0
  98. package/dist/dashboard/dashboard.controller.d.ts +101 -0
  99. package/dist/dashboard/dashboard.controller.d.ts.map +1 -0
  100. package/dist/dashboard/dashboard.controller.js +40 -0
  101. package/dist/dashboard/dashboard.controller.js.map +1 -0
  102. package/dist/dashboard/dashboard.module.d.ts +3 -0
  103. package/dist/dashboard/dashboard.module.d.ts.map +1 -0
  104. package/dist/dashboard/dashboard.module.js +25 -0
  105. package/dist/dashboard/dashboard.module.js.map +1 -0
  106. package/dist/dashboard/dashboard.service.d.ts +130 -0
  107. package/dist/dashboard/dashboard.service.d.ts.map +1 -0
  108. package/dist/dashboard/dashboard.service.js +626 -0
  109. package/dist/dashboard/dashboard.service.js.map +1 -0
  110. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts +4 -0
  111. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts.map +1 -0
  112. package/dist/enterprise/dto/add-enterprise-class-group.dto.js +22 -0
  113. package/dist/enterprise/dto/add-enterprise-class-group.dto.js.map +1 -0
  114. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts +5 -0
  115. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts.map +1 -0
  116. package/dist/enterprise/dto/add-enterprise-course.dto.js +27 -0
  117. package/dist/enterprise/dto/add-enterprise-course.dto.js.map +1 -0
  118. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts +5 -0
  119. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  121. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  123. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  125. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  126. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  127. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  129. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  130. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  131. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  133. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  135. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  137. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  139. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  141. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  142. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  143. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  144. package/dist/enterprise/enterprise.controller.js +311 -0
  145. package/dist/enterprise/enterprise.controller.js.map +1 -0
  146. package/dist/enterprise/enterprise.module.d.ts +3 -0
  147. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.module.js +25 -0
  149. package/dist/enterprise/enterprise.module.js.map +1 -0
  150. package/dist/enterprise/enterprise.service.d.ts +282 -0
  151. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.service.js +627 -0
  153. package/dist/enterprise/enterprise.service.js.map +1 -0
  154. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  155. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  156. package/dist/evaluation/evaluation.controller.js +76 -0
  157. package/dist/evaluation/evaluation.controller.js.map +1 -0
  158. package/dist/evaluation/evaluation.module.d.ts +3 -0
  159. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.module.js +25 -0
  161. package/dist/evaluation/evaluation.module.js.map +1 -0
  162. package/dist/evaluation/evaluation.service.d.ts +67 -0
  163. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.service.js +378 -0
  165. package/dist/evaluation/evaluation.service.js.map +1 -0
  166. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  167. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  168. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  169. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  170. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  171. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam.dto.js +63 -0
  173. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  174. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  175. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  177. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  178. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  179. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  181. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  182. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  183. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  185. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  186. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  187. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  191. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  193. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam.dto.js +9 -0
  197. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  198. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  199. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  200. package/dist/exam/exam-attempt.controller.js +84 -0
  201. package/dist/exam/exam-attempt.controller.js.map +1 -0
  202. package/dist/exam/exam-attempt.service.d.ts +302 -0
  203. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.service.js +776 -0
  205. package/dist/exam/exam-attempt.service.js.map +1 -0
  206. package/dist/exam/exam.controller.d.ts +162 -0
  207. package/dist/exam/exam.controller.d.ts.map +1 -0
  208. package/dist/exam/exam.controller.js +158 -0
  209. package/dist/exam/exam.controller.js.map +1 -0
  210. package/dist/exam/exam.module.d.ts +3 -0
  211. package/dist/exam/exam.module.d.ts.map +1 -0
  212. package/dist/exam/exam.module.js +27 -0
  213. package/dist/exam/exam.module.js.map +1 -0
  214. package/dist/exam/exam.service.d.ts +179 -0
  215. package/dist/exam/exam.service.d.ts.map +1 -0
  216. package/dist/exam/exam.service.js +597 -0
  217. package/dist/exam/exam.service.js.map +1 -0
  218. package/dist/index.d.ts +28 -0
  219. package/dist/index.d.ts.map +1 -1
  220. package/dist/index.js +28 -0
  221. package/dist/index.js.map +1 -1
  222. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  223. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  224. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  225. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  226. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  227. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  229. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  230. package/dist/instructor/instructor.controller.d.ts +52 -0
  231. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  232. package/dist/instructor/instructor.controller.js +98 -0
  233. package/dist/instructor/instructor.controller.js.map +1 -0
  234. package/dist/instructor/instructor.module.d.ts +3 -0
  235. package/dist/instructor/instructor.module.d.ts.map +1 -0
  236. package/dist/instructor/instructor.module.js +25 -0
  237. package/dist/instructor/instructor.module.js.map +1 -0
  238. package/dist/instructor/instructor.service.d.ts +79 -0
  239. package/dist/instructor/instructor.service.d.ts.map +1 -0
  240. package/dist/instructor/instructor.service.js +528 -0
  241. package/dist/instructor/instructor.service.js.map +1 -0
  242. package/dist/lms.module.d.ts.map +1 -1
  243. package/dist/lms.module.js +36 -4
  244. package/dist/lms.module.js.map +1 -1
  245. package/dist/reports/reports.controller.d.ts +69 -0
  246. package/dist/reports/reports.controller.d.ts.map +1 -0
  247. package/dist/reports/reports.controller.js +40 -0
  248. package/dist/reports/reports.controller.js.map +1 -0
  249. package/dist/reports/reports.module.d.ts +3 -0
  250. package/dist/reports/reports.module.d.ts.map +1 -0
  251. package/dist/reports/reports.module.js +25 -0
  252. package/dist/reports/reports.module.js.map +1 -0
  253. package/dist/reports/reports.service.d.ts +80 -0
  254. package/dist/reports/reports.service.d.ts.map +1 -0
  255. package/dist/reports/reports.service.js +366 -0
  256. package/dist/reports/reports.service.js.map +1 -0
  257. package/dist/training/dto/create-training.dto.d.ts +19 -0
  258. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  259. package/dist/training/dto/create-training.dto.js +98 -0
  260. package/dist/training/dto/create-training.dto.js.map +1 -0
  261. package/dist/training/dto/update-training.dto.d.ts +6 -0
  262. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/update-training.dto.js +9 -0
  264. package/dist/training/dto/update-training.dto.js.map +1 -0
  265. package/dist/training/training.controller.d.ts +195 -0
  266. package/dist/training/training.controller.d.ts.map +1 -0
  267. package/dist/training/training.controller.js +104 -0
  268. package/dist/training/training.controller.js.map +1 -0
  269. package/dist/training/training.module.d.ts +3 -0
  270. package/dist/training/training.module.d.ts.map +1 -0
  271. package/dist/training/training.module.js +25 -0
  272. package/dist/training/training.module.js.map +1 -0
  273. package/dist/training/training.service.d.ts +213 -0
  274. package/dist/training/training.service.d.ts.map +1 -0
  275. package/dist/training/training.service.js +497 -0
  276. package/dist/training/training.service.js.map +1 -0
  277. package/hedhog/data/dashboard.yaml +6 -0
  278. package/hedhog/data/dashboard_component.yaml +153 -0
  279. package/hedhog/data/dashboard_component_role.yaml +97 -0
  280. package/hedhog/data/dashboard_item.yaml +167 -0
  281. package/hedhog/data/dashboard_role.yaml +6 -0
  282. package/hedhog/data/instructor_qualification.yaml +16 -0
  283. package/hedhog/data/menu.yaml +129 -19
  284. package/hedhog/data/role.yaml +25 -1
  285. package/hedhog/data/route.yaml +867 -0
  286. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  287. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  288. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  289. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  290. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  291. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  292. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  293. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  294. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  295. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  296. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  297. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  298. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  299. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  300. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  301. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  302. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  303. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  304. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  305. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  306. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  307. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  308. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  309. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  310. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  311. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  312. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  313. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  322. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  323. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  324. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  325. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  326. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  327. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  328. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  329. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +58 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +224 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  348. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  349. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  350. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  351. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  352. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  353. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  354. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  355. package/hedhog/frontend/app/page.tsx.ejs +3 -1220
  356. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  357. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  358. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  359. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  360. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  361. package/hedhog/frontend/messages/en.json +1606 -111
  362. package/hedhog/frontend/messages/pt.json +1636 -134
  363. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  364. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  365. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  366. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  367. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  368. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  369. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  370. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  371. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  372. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  373. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  374. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  375. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  376. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  377. package/hedhog/table/course.yaml +19 -1
  378. package/hedhog/table/course_class_group.yaml +8 -0
  379. package/hedhog/table/course_class_session.yaml +33 -0
  380. package/hedhog/table/course_instructor.yaml +27 -0
  381. package/hedhog/table/enterprise.yaml +29 -0
  382. package/hedhog/table/enterprise_class_group.yaml +20 -0
  383. package/hedhog/table/enterprise_course.yaml +23 -0
  384. package/hedhog/table/enterprise_student.yaml +24 -0
  385. package/hedhog/table/enterprise_user.yaml +35 -0
  386. package/hedhog/table/instructor_qualification.yaml +26 -0
  387. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  388. package/hedhog/table/question.yaml +6 -0
  389. package/package.json +6 -6
  390. package/src/certificate/certificate.controller.ts +83 -0
  391. package/src/certificate/certificate.module.ts +13 -0
  392. package/src/certificate/certificate.service.ts +413 -0
  393. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  394. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  395. package/src/class-group/class-group.controller.ts +189 -0
  396. package/src/class-group/class-group.module.ts +12 -0
  397. package/src/class-group/class-group.service.ts +1802 -0
  398. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  399. package/src/class-group/dto/create-session.dto.ts +102 -0
  400. package/src/class-group/dto/enrollment.dto.ts +70 -0
  401. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  402. package/src/class-group/dto/update-session.dto.ts +9 -0
  403. package/src/course/course-structure.controller.ts +85 -0
  404. package/src/course/course-structure.service.ts +525 -0
  405. package/src/course/course.controller.ts +69 -0
  406. package/src/course/course.module.ts +15 -0
  407. package/src/course/course.service.ts +920 -0
  408. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  409. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  410. package/src/course/dto/create-course.dto.ts +111 -0
  411. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  412. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  413. package/src/course/dto/update-course.dto.ts +4 -0
  414. package/src/dashboard/dashboard.controller.ts +14 -0
  415. package/src/dashboard/dashboard.module.ts +12 -0
  416. package/src/dashboard/dashboard.service.ts +726 -0
  417. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  418. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  419. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  420. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  421. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  422. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  423. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  424. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  425. package/src/enterprise/enterprise.controller.ts +233 -0
  426. package/src/enterprise/enterprise.module.ts +12 -0
  427. package/src/enterprise/enterprise.service.ts +712 -0
  428. package/src/evaluation/evaluation.controller.ts +44 -0
  429. package/src/evaluation/evaluation.module.ts +12 -0
  430. package/src/evaluation/evaluation.service.ts +394 -0
  431. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  432. package/src/exam/dto/create-exam.dto.ts +41 -0
  433. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  434. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  435. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  436. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  437. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  438. package/src/exam/dto/update-exam.dto.ts +4 -0
  439. package/src/exam/exam-attempt.controller.ts +65 -0
  440. package/src/exam/exam-attempt.service.ts +1008 -0
  441. package/src/exam/exam.controller.ts +102 -0
  442. package/src/exam/exam.module.ts +14 -0
  443. package/src/exam/exam.service.ts +784 -0
  444. package/src/index.ts +29 -0
  445. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  446. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  447. package/src/instructor/instructor.controller.ts +73 -0
  448. package/src/instructor/instructor.module.ts +12 -0
  449. package/src/instructor/instructor.service.ts +646 -0
  450. package/src/lms.module.ts +36 -4
  451. package/src/reports/reports.controller.ts +14 -0
  452. package/src/reports/reports.module.ts +12 -0
  453. package/src/reports/reports.service.ts +485 -0
  454. package/src/training/dto/create-training.dto.ts +81 -0
  455. package/src/training/dto/update-training.dto.ts +4 -0
  456. package/src/training/training.controller.ts +68 -0
  457. package/src/training/training.module.ts +12 -0
  458. package/src/training/training.service.ts +574 -0
@@ -0,0 +1,1992 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { Calendar } from '@/components/ui/calendar';
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandGroup,
9
+ CommandInput,
10
+ CommandItem,
11
+ CommandList,
12
+ } from '@/components/ui/command';
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from '@/components/ui/dialog';
21
+ import { EntityPicker } from '@/components/ui/entity-picker';
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
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
33
+ } from '@/components/ui/popover';
34
+ import {
35
+ Select,
36
+ SelectContent,
37
+ SelectItem,
38
+ SelectTrigger,
39
+ SelectValue,
40
+ } from '@/components/ui/select';
41
+ import {
42
+ Sheet,
43
+ SheetContent,
44
+ SheetDescription,
45
+ SheetFooter,
46
+ SheetHeader,
47
+ SheetTitle,
48
+ } from '@/components/ui/sheet';
49
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
50
+ import { zodResolver } from '@hookform/resolvers/zod';
51
+ import { format } from 'date-fns';
52
+ import { CalendarIcon, ChevronsUpDown, Loader2, Plus } from 'lucide-react';
53
+ import { useTranslations } from 'next-intl';
54
+ import { useEffect, useMemo, useRef, useState } from 'react';
55
+ import type { DateRange } from 'react-day-picker';
56
+ import { Controller, useForm, useWatch } from 'react-hook-form';
57
+ import { toast } from 'sonner';
58
+ import { z } from 'zod';
59
+ import {
60
+ CourseFormSheet,
61
+ DEFAULT_COURSE_FORM_VALUES,
62
+ getCourseSheetSchema,
63
+ type CourseCategoryOption,
64
+ type CourseSheetFormValues,
65
+ } from './course-form-sheet';
66
+ import { CreateLmsPersonSheet } from './create-lms-person-sheet';
67
+
68
+ // ── Types ─────────────────────────────────────────────────────────────────────
69
+
70
+ type SessionRecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
71
+ type SessionRecurrenceMode =
72
+ | 'none'
73
+ | 'daily'
74
+ | 'weekly'
75
+ | 'monthly'
76
+ | 'yearly'
77
+ | 'weekdays'
78
+ | 'custom';
79
+ type SessionRecurrenceDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
80
+
81
+ type SessionRecurrenceSummary = {
82
+ frequency?: SessionRecurrenceFrequency;
83
+ interval?: number;
84
+ until?: string;
85
+ daysOfWeek?: SessionRecurrenceDay[];
86
+ isRecurring: boolean;
87
+ };
88
+
89
+ export type ApiClass = {
90
+ id: number;
91
+ code: string;
92
+ title: string;
93
+ deliveryMode: 'presential' | 'online' | 'hybrid';
94
+ status: 'open' | 'ongoing' | 'completed' | 'cancelled';
95
+ startDate: string;
96
+ endDate: string | null;
97
+ startTime: string | null;
98
+ endTime: string | null;
99
+ capacity: number;
100
+ courseId: number;
101
+ instructorId?: number | null;
102
+ courseTitle: string;
103
+ enrolledCount: number;
104
+ professor?: string | null;
105
+ professorName?: string | null;
106
+ instructor?: string | null;
107
+ instructorName?: string | null;
108
+ sessionTitle?: string | null;
109
+ sessionRecurrenceSummary?: SessionRecurrenceSummary | null;
110
+ };
111
+
112
+ type ApiCourseList = {
113
+ data: Array<{ id: number; title: string }>;
114
+ total?: number;
115
+ page?: number;
116
+ pageSize?: number;
117
+ lastPage?: number;
118
+ };
119
+
120
+ type ApiCategoryList = {
121
+ data: ApiCategory[];
122
+ total: number;
123
+ page: number;
124
+ pageSize: number;
125
+ };
126
+
127
+ type ApiCategory = {
128
+ id: number;
129
+ slug: string;
130
+ name: string;
131
+ status?: 'active' | 'inactive';
132
+ };
133
+
134
+ type ApiCreatedCourse = {
135
+ id: number;
136
+ title: string;
137
+ };
138
+
139
+ type InstructorOption = {
140
+ id: number;
141
+ name: string;
142
+ personId?: number;
143
+ qualificationSlugs?: string[];
144
+ };
145
+
146
+ type InstructorApiRow = {
147
+ id?: number | string;
148
+ instructor_id?: number | string;
149
+ value?: number | string;
150
+ name?: string;
151
+ nome?: string;
152
+ full_name?: string;
153
+ label?: string;
154
+ personId?: number | string;
155
+ person_id?: number | string;
156
+ qualificationSlugs?: string[];
157
+ };
158
+
159
+ type TurmaForm = {
160
+ codigo: string;
161
+ curso: string;
162
+ courseId?: number;
163
+ instructorId?: number;
164
+ tipo: string;
165
+ professor: string;
166
+ vagas: number;
167
+ dataInicio: string;
168
+ dataFim: string;
169
+ horarioInicio: string;
170
+ horarioFim: string;
171
+ sessionRecurrenceMode: SessionRecurrenceMode;
172
+ sessionRecurrenceCustomFrequency: SessionRecurrenceFrequency;
173
+ sessionRecurrenceInterval: number;
174
+ sessionRecurrenceDaysOfWeek: SessionRecurrenceDay[];
175
+ sessionRecurrenceUntil?: string;
176
+ sessionTitleMode: 'default-course-code' | 'custom';
177
+ sessionTitle?: string;
178
+ status: string;
179
+ };
180
+
181
+ // ── Helpers ───────────────────────────────────────────────────────────────────
182
+
183
+ function parseFormDate(value: string) {
184
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
185
+ if (!match) return null;
186
+ return new Date(
187
+ Number(match[1]),
188
+ Number(match[2]) - 1,
189
+ Number(match[3]),
190
+ 12,
191
+ 0,
192
+ 0,
193
+ 0
194
+ );
195
+ }
196
+
197
+ function getDayCodeFromDate(value?: string): SessionRecurrenceDay {
198
+ const date = parseFormDate(value ?? '') ?? new Date();
199
+ return ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][
200
+ date.getDay()
201
+ ] as SessionRecurrenceDay;
202
+ }
203
+
204
+ function getDefaultSessionTitle(courseTitle?: string, code?: string) {
205
+ return [courseTitle?.trim(), code?.trim()].filter(Boolean).join(' - ');
206
+ }
207
+
208
+ function toApiType(tipo: string) {
209
+ if (tipo === 'presencial') return 'presential';
210
+ if (tipo === 'hibrida') return 'hybrid';
211
+ return 'online';
212
+ }
213
+
214
+ function toPtType(tipo: ApiClass['deliveryMode']): TurmaForm['tipo'] {
215
+ if (tipo === 'presential') return 'presencial';
216
+ if (tipo === 'hybrid') return 'hibrida';
217
+ return 'online';
218
+ }
219
+
220
+ function toApiStatus(status: string) {
221
+ if (status === 'aberta') return 'open';
222
+ if (status === 'em_andamento') return 'ongoing';
223
+ if (status === 'concluida') return 'completed';
224
+ return 'cancelled';
225
+ }
226
+
227
+ function toPtStatus(status: ApiClass['status']): TurmaForm['status'] {
228
+ if (status === 'open') return 'aberta';
229
+ if (status === 'ongoing') return 'em_andamento';
230
+ if (status === 'completed') return 'concluida';
231
+ return 'cancelada';
232
+ }
233
+
234
+ function getDateOnly(value?: string | null) {
235
+ if (!value) return '';
236
+ return value.slice(0, 10);
237
+ }
238
+
239
+ function formatDate(d: string) {
240
+ const [y, m, day] = d.split('-');
241
+ return `${day}/${m}/${y}`;
242
+ }
243
+
244
+ function formatDateRangeLabel(start?: string, end?: string) {
245
+ if (start && end) return `${formatDate(start)} - ${formatDate(end)}`;
246
+ if (start) return formatDate(start);
247
+ return '';
248
+ }
249
+
250
+ function buildSessionRecurrencePayload(values: TurmaForm) {
251
+ if (values.sessionRecurrenceMode === 'none') return undefined;
252
+
253
+ const frequency =
254
+ values.sessionRecurrenceMode === 'custom'
255
+ ? values.sessionRecurrenceCustomFrequency
256
+ : values.sessionRecurrenceMode === 'weekdays'
257
+ ? 'weekly'
258
+ : values.sessionRecurrenceMode;
259
+
260
+ const daysOfWeek =
261
+ values.sessionRecurrenceMode === 'weekdays'
262
+ ? (['MO', 'TU', 'WE', 'TH', 'FR'] as SessionRecurrenceDay[])
263
+ : frequency === 'weekly'
264
+ ? values.sessionRecurrenceDaysOfWeek
265
+ : undefined;
266
+
267
+ return {
268
+ frequency,
269
+ interval: values.sessionRecurrenceInterval,
270
+ until: values.sessionRecurrenceUntil!,
271
+ ...(daysOfWeek?.length ? { daysOfWeek } : {}),
272
+ };
273
+ }
274
+
275
+ function inferRecurrenceMode(
276
+ summary?: SessionRecurrenceSummary | null
277
+ ): SessionRecurrenceMode {
278
+ if (!summary?.isRecurring || !summary.frequency) return 'none';
279
+
280
+ if (
281
+ summary.frequency === 'weekly' &&
282
+ Array.isArray(summary.daysOfWeek) &&
283
+ summary.daysOfWeek.join(',') === 'MO,TU,WE,TH,FR' &&
284
+ (summary.interval ?? 1) === 1
285
+ ) {
286
+ return 'weekdays';
287
+ }
288
+
289
+ if ((summary.interval ?? 1) !== 1) return 'custom';
290
+
291
+ return summary.frequency;
292
+ }
293
+
294
+ function getSuggestedEndTime(startTime?: string) {
295
+ if (!startTime) return '';
296
+ const startIndex = TIME_OPTIONS.indexOf(startTime);
297
+ if (startIndex === -1) return '';
298
+ return TIME_OPTIONS[Math.min(startIndex + 1, TIME_OPTIONS.length - 1)] ?? '';
299
+ }
300
+
301
+ function normalizeInstructorOption(
302
+ item: InstructorApiRow
303
+ ): InstructorOption | null {
304
+ const id = Number(item?.id ?? item?.instructor_id ?? item?.value ?? 0);
305
+ const name = String(
306
+ item?.name ?? item?.nome ?? item?.full_name ?? item?.label ?? ''
307
+ ).trim();
308
+ if (!id || !name) return null;
309
+ return {
310
+ id,
311
+ name,
312
+ personId: Number(item?.personId ?? item?.person_id ?? 0) || undefined,
313
+ qualificationSlugs: Array.isArray(item?.qualificationSlugs)
314
+ ? item.qualificationSlugs
315
+ : undefined,
316
+ };
317
+ }
318
+
319
+ function getContrastColor(hex: string) {
320
+ const cleaned = hex.replace('#', '');
321
+ if (cleaned.length !== 6) return '#FFFFFF';
322
+ const r = parseInt(cleaned.slice(0, 2), 16);
323
+ const g = parseInt(cleaned.slice(2, 4), 16);
324
+ const b = parseInt(cleaned.slice(4, 6), 16);
325
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
326
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
327
+ }
328
+
329
+ function toApiCourseLevel(level: CourseSheetFormValues['nivel']) {
330
+ if (level === 'iniciante') return 'beginner';
331
+ if (level === 'intermediario') return 'intermediate';
332
+ return 'advanced';
333
+ }
334
+
335
+ function toApiCourseStatus(status: CourseSheetFormValues['status']) {
336
+ if (status === 'ativo') return 'published';
337
+ if (status === 'rascunho') return 'draft';
338
+ return 'archived';
339
+ }
340
+
341
+ // ── Constants ─────────────────────────────────────────────────────────────────
342
+
343
+ const TIME_OPTIONS = Array.from({ length: 32 }, (_, index) => {
344
+ const hour = 6 + Math.floor(index / 2);
345
+ const minute = index % 2 === 0 ? '00' : '30';
346
+ return `${String(hour).padStart(2, '0')}:${minute}`;
347
+ });
348
+
349
+ function createClassCodeSeed() {
350
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
351
+ .replace(/[^a-z0-9]/gi, '')
352
+ .toUpperCase();
353
+ }
354
+
355
+ function buildClassCode(courseTitle?: string, seed?: string) {
356
+ const normalizedPrefix = (courseTitle ?? '')
357
+ .normalize('NFD')
358
+ .replace(/[\u0300-\u036f]/g, '')
359
+ .replace(/[^a-z0-9]/gi, '')
360
+ .toUpperCase()
361
+ .slice(0, 4);
362
+
363
+ const prefix = normalizedPrefix || 'TURM';
364
+ const codeSeed = seed || createClassCodeSeed();
365
+
366
+ return `${prefix}-${codeSeed}`;
367
+ }
368
+
369
+ function getTurmaSchema(t: (key: string) => string) {
370
+ return z
371
+ .object({
372
+ codigo: z.string().min(3, t('form.validation.codigoMinLength')),
373
+ curso: z.string().optional(),
374
+ courseId: z.coerce
375
+ .number({ invalid_type_error: t('form.validation.cursoRequired') })
376
+ .int()
377
+ .positive(t('form.validation.cursoRequired')),
378
+ tipo: z.string().min(1, t('form.validation.tipoRequired')),
379
+ professor: z.string().min(3, t('form.validation.professorMinLength')),
380
+ vagas: z.coerce.number().min(1, t('form.validation.vagasMin')),
381
+ dataInicio: z.string().min(1, t('form.validation.dataInicioRequired')),
382
+ dataFim: z.string().min(1, t('form.validation.dataFimRequired')),
383
+ horarioInicio: z
384
+ .string()
385
+ .min(1, t('form.validation.horarioInicioRequired'))
386
+ .regex(
387
+ /^([01]\d|2[0-3]):([0-5]\d)$/,
388
+ t('form.validation.horarioFormato')
389
+ ),
390
+ horarioFim: z
391
+ .string()
392
+ .min(1, t('form.validation.horarioFimRequired'))
393
+ .regex(
394
+ /^([01]\d|2[0-3]):([0-5]\d)$/,
395
+ t('form.validation.horarioFormato')
396
+ ),
397
+ sessionRecurrenceMode: z
398
+ .enum([
399
+ 'none',
400
+ 'daily',
401
+ 'weekly',
402
+ 'monthly',
403
+ 'yearly',
404
+ 'weekdays',
405
+ 'custom',
406
+ ] as const)
407
+ .default('none'),
408
+ sessionRecurrenceCustomFrequency: z
409
+ .enum(['daily', 'weekly', 'monthly', 'yearly'] as const)
410
+ .default('weekly'),
411
+ sessionRecurrenceInterval: z.coerce
412
+ .number()
413
+ .min(1, t('form.validation.sessionRecurrenceIntervalMin'))
414
+ .default(1),
415
+ sessionRecurrenceDaysOfWeek: z
416
+ .array(z.enum(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const))
417
+ .default([]),
418
+ sessionRecurrenceUntil: z.string().optional(),
419
+ sessionTitleMode: z
420
+ .enum(['default-course-code', 'custom'] as const)
421
+ .default('default-course-code'),
422
+ sessionTitle: z.string().optional(),
423
+ status: z.string().min(1, t('form.validation.statusRequired')),
424
+ instructorId: z.number().int().positive().optional(),
425
+ })
426
+ .superRefine((values, ctx) => {
427
+ if (
428
+ values.dataInicio &&
429
+ values.dataFim &&
430
+ values.dataFim < values.dataInicio
431
+ ) {
432
+ ctx.addIssue({
433
+ code: z.ZodIssueCode.custom,
434
+ path: ['dataFim'],
435
+ message: 'A data final nao pode ser anterior a data inicial.',
436
+ });
437
+ }
438
+
439
+ if (
440
+ values.horarioInicio &&
441
+ values.horarioFim &&
442
+ values.horarioFim < values.horarioInicio
443
+ ) {
444
+ ctx.addIssue({
445
+ code: z.ZodIssueCode.custom,
446
+ path: ['horarioFim'],
447
+ message: 'O horario final nao pode ser anterior ao horario inicial.',
448
+ });
449
+ }
450
+
451
+ if (values.sessionRecurrenceMode !== 'none') {
452
+ if (!values.sessionRecurrenceUntil) {
453
+ ctx.addIssue({
454
+ code: z.ZodIssueCode.custom,
455
+ path: ['sessionRecurrenceUntil'],
456
+ message: t('form.validation.sessionRecurrenceUntilRequired'),
457
+ });
458
+ } else if (values.sessionRecurrenceUntil < values.dataInicio) {
459
+ ctx.addIssue({
460
+ code: z.ZodIssueCode.custom,
461
+ path: ['sessionRecurrenceUntil'],
462
+ message: t('form.validation.sessionRecurrenceUntilAfterStart'),
463
+ });
464
+ }
465
+ }
466
+
467
+ const requiresDays =
468
+ values.sessionRecurrenceMode === 'weekly' ||
469
+ values.sessionRecurrenceMode === 'weekdays' ||
470
+ (values.sessionRecurrenceMode === 'custom' &&
471
+ values.sessionRecurrenceCustomFrequency === 'weekly');
472
+
473
+ if (requiresDays && values.sessionRecurrenceDaysOfWeek.length === 0) {
474
+ ctx.addIssue({
475
+ code: z.ZodIssueCode.custom,
476
+ path: ['sessionRecurrenceDaysOfWeek'],
477
+ message: t('form.validation.sessionRecurrenceDaysRequired'),
478
+ });
479
+ }
480
+ });
481
+ }
482
+
483
+ // ── Props ─────────────────────────────────────────────────────────────────────
484
+
485
+ export interface ClassFormSheetProps {
486
+ open: boolean;
487
+ onOpenChange: (open: boolean) => void;
488
+ /** Omit for create mode; provide for edit mode. */
489
+ classId?: string;
490
+ sheetTitle?: string;
491
+ sheetDescription?: string;
492
+ onAfterCreate?: (cls: ApiClass) => Promise<void>;
493
+ onSuccess?: () => void;
494
+ /** @deprecated Use onSuccess */
495
+ onSaved?: () => void;
496
+ }
497
+
498
+ // ── Component ─────────────────────────────────────────────────────────────────
499
+
500
+ export function ClassFormSheet({
501
+ open,
502
+ onOpenChange,
503
+ classId,
504
+ sheetTitle,
505
+ sheetDescription,
506
+ onAfterCreate,
507
+ onSuccess,
508
+ onSaved,
509
+ }: ClassFormSheetProps) {
510
+ const t = useTranslations('lms.ClassesPage');
511
+ const courseSheetT = useTranslations('lms.CoursesPage');
512
+ const { request } = useApp();
513
+
514
+ const [loading, setLoading] = useState(false);
515
+ const [saving, setSaving] = useState(false);
516
+ const [savingCourse, setSavingCourse] = useState(false);
517
+ const [dateRangeOpen, setDateRangeOpen] = useState(false);
518
+ const [dateRangeDraft, setDateRangeDraft] = useState<DateRange | undefined>();
519
+ const [professorOpen, setProfessorOpen] = useState(false);
520
+ const [professorSearch, setProfessorSearch] = useState('');
521
+ const [createProfessorDialogOpen, setCreateProfessorDialogOpen] =
522
+ useState(false);
523
+ const [courseSheetOpen, setCourseSheetOpen] = useState(false);
524
+ const [customRecurrenceDialogOpen, setCustomRecurrenceDialogOpen] =
525
+ useState(false);
526
+ const [previousRecurrenceMode, setPreviousRecurrenceMode] =
527
+ useState<SessionRecurrenceMode>('none');
528
+ const customRecurrenceConfirmedRef = useRef(false);
529
+
530
+ const form = useForm<TurmaForm>({
531
+ resolver: zodResolver(getTurmaSchema(t)),
532
+ defaultValues: {
533
+ codigo: '',
534
+ curso: '',
535
+ courseId: undefined,
536
+ instructorId: undefined,
537
+ tipo: 'online',
538
+ professor: '',
539
+ vagas: 30,
540
+ dataInicio: '',
541
+ dataFim: '',
542
+ horarioInicio: '',
543
+ horarioFim: '',
544
+ sessionRecurrenceMode: 'none',
545
+ sessionRecurrenceCustomFrequency: 'weekly',
546
+ sessionRecurrenceInterval: 1,
547
+ sessionRecurrenceDaysOfWeek: [],
548
+ sessionRecurrenceUntil: '',
549
+ sessionTitleMode: 'default-course-code',
550
+ sessionTitle: '',
551
+ status: 'aberta',
552
+ },
553
+ });
554
+
555
+ const courseForm = useForm<CourseSheetFormValues>({
556
+ resolver: zodResolver(getCourseSheetSchema(courseSheetT)),
557
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
558
+ });
559
+
560
+ const watchedFormValues = useWatch({ control: form.control });
561
+
562
+ // ── Queries ────────────────────────────────────────────────────────────────
563
+
564
+ const {
565
+ data: professorOptions = [],
566
+ isFetching: loadingProfessores,
567
+ refetch: refetchProfessorOptions,
568
+ } = useQuery<InstructorOption[]>({
569
+ queryKey: ['class-form-sheet-professors', professorSearch],
570
+ queryFn: async () => {
571
+ const response = await request<
572
+ | InstructorApiRow[]
573
+ | {
574
+ data?: InstructorApiRow[];
575
+ items?: InstructorApiRow[];
576
+ rows?: InstructorApiRow[];
577
+ }
578
+ >({
579
+ url: '/lms/instructors',
580
+ method: 'GET',
581
+ params: {
582
+ page: 1,
583
+ pageSize: 100,
584
+ qualificationSlugs: ['class-sessions'],
585
+ ...(professorSearch.trim() ? { search: professorSearch.trim() } : {}),
586
+ },
587
+ });
588
+
589
+ const payload = response.data;
590
+ const rows = Array.isArray(payload)
591
+ ? payload
592
+ : Array.isArray(payload?.data)
593
+ ? payload.data
594
+ : Array.isArray(payload?.items)
595
+ ? payload.items
596
+ : Array.isArray(payload?.rows)
597
+ ? payload.rows
598
+ : [];
599
+
600
+ const unique = new Map<number, InstructorOption>();
601
+ for (const row of rows) {
602
+ const normalized = normalizeInstructorOption(row);
603
+ if (!normalized) continue;
604
+ unique.set(normalized.id, normalized);
605
+ }
606
+
607
+ return Array.from(unique.values()).sort((a, b) =>
608
+ a.name.localeCompare(b.name)
609
+ );
610
+ },
611
+ initialData: [],
612
+ });
613
+
614
+ const { data: categoryListData, refetch: refetchCategoryOptions } =
615
+ useQuery<ApiCategoryList>({
616
+ queryKey: ['class-form-sheet-categories'],
617
+ queryFn: async () => {
618
+ const response = await request<ApiCategoryList>({
619
+ url: '/category',
620
+ method: 'GET',
621
+ params: { page: 1, pageSize: 500, status: 'all' },
622
+ });
623
+
624
+ const payload = response.data as ApiCategoryList | ApiCategory[];
625
+ if (Array.isArray(payload)) {
626
+ return {
627
+ data: payload,
628
+ total: payload.length,
629
+ page: 1,
630
+ pageSize: payload.length,
631
+ };
632
+ }
633
+
634
+ return payload;
635
+ },
636
+ initialData: { data: [], total: 0, page: 1, pageSize: 500 },
637
+ });
638
+
639
+ // ── Load class data when opened ────────────────────────────────────────────
640
+
641
+ useEffect(() => {
642
+ if (!open) return;
643
+
644
+ if (classId) {
645
+ // Edit mode: load from API
646
+ setLoading(true);
647
+ request<ApiClass>({ url: `/lms/classes/${classId}`, method: 'GET' })
648
+ .then((res) => {
649
+ const data = res.data;
650
+ const recurrenceSummary = data.sessionRecurrenceSummary;
651
+ const recurrenceMode = inferRecurrenceMode(recurrenceSummary);
652
+ const dataInicio = getDateOnly(data.startDate);
653
+ const dataFim = getDateOnly(data.endDate);
654
+ const defaultDay = getDayCodeFromDate(dataInicio);
655
+ const professorName =
656
+ data.professorName ??
657
+ data.professor ??
658
+ data.instructorName ??
659
+ data.instructor ??
660
+ '';
661
+
662
+ setPreviousRecurrenceMode(recurrenceMode);
663
+ setDateRangeOpen(false);
664
+ setDateRangeDraft({
665
+ from: dataInicio ? new Date(`${dataInicio}T00:00:00`) : undefined,
666
+ to: dataFim ? new Date(`${dataFim}T00:00:00`) : undefined,
667
+ });
668
+
669
+ form.reset({
670
+ codigo: data.code,
671
+ curso: data.courseTitle,
672
+ courseId: data.courseId,
673
+ instructorId: data.instructorId ?? undefined,
674
+ tipo: toPtType(data.deliveryMode),
675
+ professor: professorName?.trim() || '',
676
+ vagas: data.capacity,
677
+ dataInicio,
678
+ dataFim: dataFim ?? '',
679
+ horarioInicio: data.startTime ?? '',
680
+ horarioFim: data.endTime ?? '',
681
+ sessionRecurrenceMode: recurrenceMode,
682
+ sessionRecurrenceCustomFrequency:
683
+ recurrenceSummary?.frequency ?? 'weekly',
684
+ sessionRecurrenceInterval: recurrenceSummary?.interval ?? 1,
685
+ sessionRecurrenceDaysOfWeek:
686
+ recurrenceSummary?.daysOfWeek ??
687
+ (recurrenceMode === 'weekly' ? [defaultDay] : []),
688
+ sessionRecurrenceUntil: recurrenceSummary?.until ?? dataFim ?? '',
689
+ sessionTitleMode:
690
+ data.sessionTitle &&
691
+ data.sessionTitle !==
692
+ getDefaultSessionTitle(data.courseTitle, data.code)
693
+ ? 'custom'
694
+ : 'default-course-code',
695
+ sessionTitle:
696
+ data.sessionTitle ??
697
+ getDefaultSessionTitle(data.courseTitle, data.code),
698
+ status: toPtStatus(data.status),
699
+ });
700
+ })
701
+ .catch(() => {
702
+ toast.error('Não foi possível carregar os dados da turma.');
703
+ })
704
+ .finally(() => {
705
+ setLoading(false);
706
+ });
707
+ } else {
708
+ // Create mode: reset with fresh defaults + generated code
709
+ const nextSeed = createClassCodeSeed();
710
+ const nextCode = buildClassCode(undefined, nextSeed);
711
+ setDateRangeDraft(undefined);
712
+ setDateRangeOpen(false);
713
+ setPreviousRecurrenceMode('none');
714
+ form.reset({
715
+ codigo: nextCode,
716
+ curso: '',
717
+ courseId: undefined,
718
+ instructorId: undefined,
719
+ tipo: 'online',
720
+ professor: '',
721
+ vagas: 30,
722
+ dataInicio: '',
723
+ dataFim: '',
724
+ horarioInicio: '',
725
+ horarioFim: '',
726
+ sessionRecurrenceMode: 'none',
727
+ sessionRecurrenceCustomFrequency: 'weekly',
728
+ sessionRecurrenceInterval: 1,
729
+ sessionRecurrenceDaysOfWeek: [],
730
+ sessionRecurrenceUntil: '',
731
+ sessionTitleMode: 'default-course-code',
732
+ sessionTitle: getDefaultSessionTitle(undefined, nextCode),
733
+ status: 'aberta',
734
+ });
735
+ }
736
+ // eslint-disable-next-line react-hooks/exhaustive-deps
737
+ }, [open, classId]);
738
+
739
+ useEffect(() => {
740
+ if (professorOpen) void refetchProfessorOptions();
741
+ }, [professorOpen, refetchProfessorOptions]);
742
+
743
+ useEffect(() => {
744
+ if (courseSheetOpen) void refetchCategoryOptions();
745
+ }, [courseSheetOpen, refetchCategoryOptions]);
746
+
747
+ // ── Derived ────────────────────────────────────────────────────────────────
748
+
749
+ const selectedCourseTitle = watchedFormValues.curso ?? '';
750
+
751
+ const defaultSessionTitle = useMemo(
752
+ () => getDefaultSessionTitle(selectedCourseTitle, watchedFormValues.codigo),
753
+ [selectedCourseTitle, watchedFormValues.codigo]
754
+ );
755
+
756
+ const filteredEndTimeOptions = useMemo(() => {
757
+ const startTime = watchedFormValues.horarioInicio ?? '';
758
+ if (!startTime) return TIME_OPTIONS;
759
+ return TIME_OPTIONS.filter((time) => time >= startTime);
760
+ }, [watchedFormValues.horarioInicio]);
761
+
762
+ const categoryOptions = useMemo<CourseCategoryOption[]>(
763
+ () =>
764
+ (categoryListData?.data ?? [])
765
+ .filter((category) => !!category.slug)
766
+ .map((category) => ({
767
+ value: category.slug,
768
+ label: category.name || category.slug,
769
+ }))
770
+ .sort((a, b) => a.label.localeCompare(b.label)),
771
+ [categoryListData]
772
+ );
773
+
774
+ const recurrenceDayOptions = useMemo(
775
+ () =>
776
+ (
777
+ [
778
+ ['MO', t('form.recurrence.customDialog.days.MO')],
779
+ ['TU', t('form.recurrence.customDialog.days.TU')],
780
+ ['WE', t('form.recurrence.customDialog.days.WE')],
781
+ ['TH', t('form.recurrence.customDialog.days.TH')],
782
+ ['FR', t('form.recurrence.customDialog.days.FR')],
783
+ ['SA', t('form.recurrence.customDialog.days.SA')],
784
+ ['SU', t('form.recurrence.customDialog.days.SU')],
785
+ ] as const
786
+ ).map(([value, label]) => ({
787
+ value: value as SessionRecurrenceDay,
788
+ label,
789
+ })),
790
+ [t]
791
+ );
792
+
793
+ const recurrenceSummaryText = useMemo(() => {
794
+ const until = watchedFormValues.sessionRecurrenceUntil
795
+ ? formatDate(watchedFormValues.sessionRecurrenceUntil)
796
+ : '--';
797
+ return t(
798
+ `form.recurrence.summary.${watchedFormValues.sessionRecurrenceMode}`,
799
+ { until }
800
+ );
801
+ }, [
802
+ t,
803
+ watchedFormValues.sessionRecurrenceMode,
804
+ watchedFormValues.sessionRecurrenceUntil,
805
+ ]);
806
+
807
+ const customRecurrenceFrequency =
808
+ watchedFormValues.sessionRecurrenceMode === 'custom'
809
+ ? watchedFormValues.sessionRecurrenceCustomFrequency
810
+ : watchedFormValues.sessionRecurrenceMode === 'weekdays'
811
+ ? 'weekly'
812
+ : watchedFormValues.sessionRecurrenceMode === 'none'
813
+ ? watchedFormValues.sessionRecurrenceCustomFrequency
814
+ : watchedFormValues.sessionRecurrenceMode;
815
+
816
+ const customRecurrenceNeedsWeekdays =
817
+ watchedFormValues.sessionRecurrenceMode === 'custom' &&
818
+ customRecurrenceFrequency === 'weekly';
819
+
820
+ // Sync sessionTitle when mode is default
821
+ useEffect(() => {
822
+ if (
823
+ watchedFormValues.sessionTitleMode !== 'default-course-code' ||
824
+ form.getValues('sessionTitle') === defaultSessionTitle
825
+ ) {
826
+ return;
827
+ }
828
+ form.setValue('sessionTitle', defaultSessionTitle, {
829
+ shouldDirty: false,
830
+ shouldTouch: false,
831
+ shouldValidate: false,
832
+ });
833
+ }, [defaultSessionTitle, form, watchedFormValues.sessionTitleMode]);
834
+
835
+ // Sync recurrenceUntil when dataFim changes
836
+ useEffect(() => {
837
+ if (!watchedFormValues.dataFim) return;
838
+ form.setValue('sessionRecurrenceUntil', watchedFormValues.dataFim, {
839
+ shouldDirty: false,
840
+ shouldTouch: false,
841
+ shouldValidate: false,
842
+ });
843
+ }, [form, watchedFormValues.dataFim]);
844
+
845
+ // Sync days of week when mode changes
846
+ useEffect(() => {
847
+ if (!watchedFormValues.dataInicio) return;
848
+ const defaultDay = getDayCodeFromDate(watchedFormValues.dataInicio);
849
+ const recurrenceMode = watchedFormValues.sessionRecurrenceMode;
850
+ const recurrenceDays = watchedFormValues.sessionRecurrenceDaysOfWeek ?? [];
851
+
852
+ if (recurrenceMode === 'weekly' && recurrenceDays.length === 0) {
853
+ form.setValue('sessionRecurrenceDaysOfWeek', [defaultDay], {
854
+ shouldDirty: false,
855
+ shouldTouch: false,
856
+ shouldValidate: false,
857
+ });
858
+ }
859
+
860
+ if (recurrenceMode === 'weekdays') {
861
+ form.setValue(
862
+ 'sessionRecurrenceDaysOfWeek',
863
+ ['MO', 'TU', 'WE', 'TH', 'FR'],
864
+ {
865
+ shouldDirty: false,
866
+ shouldTouch: false,
867
+ shouldValidate: false,
868
+ }
869
+ );
870
+ }
871
+ }, [
872
+ form,
873
+ watchedFormValues.dataInicio,
874
+ watchedFormValues.sessionRecurrenceDaysOfWeek,
875
+ watchedFormValues.sessionRecurrenceMode,
876
+ ]);
877
+
878
+ // Auto-suggest end time
879
+ useEffect(() => {
880
+ const startTime = watchedFormValues.horarioInicio;
881
+ const endTime = watchedFormValues.horarioFim;
882
+ if (!startTime) return;
883
+
884
+ if (!endTime) {
885
+ const suggestedEndTime = getSuggestedEndTime(startTime);
886
+ if (suggestedEndTime) {
887
+ form.setValue('horarioFim', suggestedEndTime, {
888
+ shouldDirty: true,
889
+ shouldTouch: false,
890
+ shouldValidate: true,
891
+ });
892
+ }
893
+ return;
894
+ }
895
+
896
+ if (endTime < startTime) {
897
+ const suggestedEndTime = getSuggestedEndTime(startTime);
898
+ form.setValue('horarioFim', suggestedEndTime || startTime, {
899
+ shouldDirty: true,
900
+ shouldTouch: true,
901
+ shouldValidate: true,
902
+ });
903
+ }
904
+ }, [form, watchedFormValues.horarioFim, watchedFormValues.horarioInicio]);
905
+
906
+ // ── Handlers ───────────────────────────────────────────────────────────────
907
+
908
+ function handleRecurrenceModeChange(value: SessionRecurrenceMode) {
909
+ if (value === 'custom') {
910
+ setPreviousRecurrenceMode(
911
+ watchedFormValues.sessionRecurrenceMode ?? 'none'
912
+ );
913
+ form.setValue('sessionRecurrenceMode', 'custom', {
914
+ shouldDirty: true,
915
+ shouldTouch: true,
916
+ shouldValidate: true,
917
+ });
918
+ setCustomRecurrenceDialogOpen(true);
919
+ return;
920
+ }
921
+
922
+ form.setValue('sessionRecurrenceMode', value, {
923
+ shouldDirty: true,
924
+ shouldTouch: true,
925
+ shouldValidate: true,
926
+ });
927
+
928
+ if (value === 'weekdays') {
929
+ form.setValue('sessionRecurrenceCustomFrequency', 'weekly', {
930
+ shouldDirty: true,
931
+ shouldTouch: false,
932
+ shouldValidate: false,
933
+ });
934
+ form.setValue('sessionRecurrenceInterval', 1, {
935
+ shouldDirty: true,
936
+ shouldTouch: false,
937
+ shouldValidate: false,
938
+ });
939
+ form.setValue(
940
+ 'sessionRecurrenceDaysOfWeek',
941
+ ['MO', 'TU', 'WE', 'TH', 'FR'],
942
+ {
943
+ shouldDirty: true,
944
+ shouldTouch: false,
945
+ shouldValidate: true,
946
+ }
947
+ );
948
+ }
949
+
950
+ if (value === 'weekly' && watchedFormValues.dataInicio) {
951
+ form.setValue(
952
+ 'sessionRecurrenceDaysOfWeek',
953
+ [getDayCodeFromDate(watchedFormValues.dataInicio)],
954
+ { shouldDirty: true, shouldTouch: false, shouldValidate: true }
955
+ );
956
+ }
957
+
958
+ setPreviousRecurrenceMode(value);
959
+ }
960
+
961
+ function toggleCustomRecurrenceDay(day: SessionRecurrenceDay) {
962
+ const currentDays = watchedFormValues.sessionRecurrenceDaysOfWeek ?? [];
963
+ const nextDays = currentDays.includes(day)
964
+ ? currentDays.filter((item) => item !== day)
965
+ : [...currentDays, day];
966
+ form.setValue('sessionRecurrenceDaysOfWeek', nextDays, {
967
+ shouldDirty: true,
968
+ shouldTouch: true,
969
+ shouldValidate: true,
970
+ });
971
+ }
972
+
973
+ function handleCustomRecurrenceCancel() {
974
+ form.setValue('sessionRecurrenceMode', previousRecurrenceMode, {
975
+ shouldDirty: true,
976
+ shouldTouch: false,
977
+ shouldValidate: true,
978
+ });
979
+ setCustomRecurrenceDialogOpen(false);
980
+ }
981
+
982
+ async function handleCustomRecurrenceConfirm() {
983
+ const valid = await form.trigger([
984
+ 'sessionRecurrenceInterval',
985
+ 'sessionRecurrenceUntil',
986
+ 'sessionRecurrenceDaysOfWeek',
987
+ 'dataInicio',
988
+ ]);
989
+ if (!valid) return;
990
+
991
+ setPreviousRecurrenceMode('custom');
992
+ customRecurrenceConfirmedRef.current = true;
993
+ form.setValue('sessionRecurrenceMode', 'custom', {
994
+ shouldDirty: true,
995
+ shouldTouch: true,
996
+ shouldValidate: true,
997
+ });
998
+ setCustomRecurrenceDialogOpen(false);
999
+ }
1000
+
1001
+ const handleProfessorCreated = async (instructor: {
1002
+ id: number;
1003
+ personId: number;
1004
+ name: string;
1005
+ qualificationSlugs: string[];
1006
+ }) => {
1007
+ form.setValue('instructorId', instructor.id, {
1008
+ shouldDirty: true,
1009
+ shouldTouch: true,
1010
+ shouldValidate: true,
1011
+ });
1012
+ form.setValue('professor', instructor.name, {
1013
+ shouldDirty: true,
1014
+ shouldTouch: true,
1015
+ shouldValidate: true,
1016
+ });
1017
+ await refetchProfessorOptions();
1018
+ };
1019
+
1020
+ async function onSubmitCourse(data: CourseSheetFormValues) {
1021
+ setSavingCourse(true);
1022
+ try {
1023
+ const payload = {
1024
+ slug: data.nomeInterno.trim(),
1025
+ title: data.tituloComercial,
1026
+ description: data.descricao,
1027
+ level: toApiCourseLevel(data.nivel),
1028
+ status: toApiCourseStatus(data.status),
1029
+ categorySlugs: data.categorias,
1030
+ primaryColor: data.primaryColor,
1031
+ primaryContrastColor: getContrastColor(data.primaryColor),
1032
+ secondaryColor: data.secondaryColor,
1033
+ secondaryContrastColor: getContrastColor(data.secondaryColor),
1034
+ };
1035
+
1036
+ const response = await request<ApiCreatedCourse>({
1037
+ url: '/lms/courses',
1038
+ method: 'POST',
1039
+ data: payload,
1040
+ });
1041
+
1042
+ const createdCourse = response.data;
1043
+ form.setValue('courseId', createdCourse.id, {
1044
+ shouldDirty: true,
1045
+ shouldTouch: true,
1046
+ shouldValidate: true,
1047
+ });
1048
+ form.setValue('curso', createdCourse.title, {
1049
+ shouldDirty: true,
1050
+ shouldTouch: true,
1051
+ shouldValidate: false,
1052
+ });
1053
+ setCourseSheetOpen(false);
1054
+ toast.success(courseSheetT('toasts.courseCreated'));
1055
+ } catch {
1056
+ toast.error('Não foi possível cadastrar o curso.');
1057
+ } finally {
1058
+ setSavingCourse(false);
1059
+ }
1060
+ }
1061
+
1062
+ async function onSubmit(data: TurmaForm) {
1063
+ setSaving(true);
1064
+ try {
1065
+ const courseId = data.courseId;
1066
+ const courseName = data.curso;
1067
+ const instructorId = data.instructorId ?? form.getValues('instructorId');
1068
+ const sessionTitle =
1069
+ data.sessionTitleMode === 'custom'
1070
+ ? data.sessionTitle?.trim() || defaultSessionTitle
1071
+ : defaultSessionTitle;
1072
+ const sessionTemplate = {
1073
+ title: sessionTitle,
1074
+ recurrence: buildSessionRecurrencePayload(data),
1075
+ };
1076
+
1077
+ const payload = {
1078
+ code: data.codigo,
1079
+ title: `${courseName} - ${data.codigo}`,
1080
+ courseId,
1081
+ instructorId,
1082
+ deliveryMode: toApiType(data.tipo),
1083
+ status: toApiStatus(data.status),
1084
+ startDate: data.dataInicio,
1085
+ endDate: data.dataFim || null,
1086
+ startTime: data.horarioInicio,
1087
+ endTime: data.horarioFim,
1088
+ capacity: data.vagas,
1089
+ sessionTemplate,
1090
+ };
1091
+
1092
+ if (classId) {
1093
+ await request({
1094
+ url: `/lms/classes/${classId}`,
1095
+ method: 'PATCH',
1096
+ data: payload,
1097
+ });
1098
+ toast.success(t('toasts.turmaUpdated'));
1099
+ } else {
1100
+ const response = await request<ApiClass>({
1101
+ url: '/lms/classes',
1102
+ method: 'POST',
1103
+ data: payload,
1104
+ });
1105
+ toast.success(t('toasts.turmaCreated'));
1106
+ if (onAfterCreate && response.data) {
1107
+ await onAfterCreate(response.data);
1108
+ }
1109
+ }
1110
+
1111
+ onOpenChange(false);
1112
+ onSaved?.();
1113
+ onSuccess?.();
1114
+ } catch {
1115
+ toast.error('Não foi possível salvar a turma.');
1116
+ } finally {
1117
+ setSaving(false);
1118
+ }
1119
+ }
1120
+
1121
+ // ── Render ─────────────────────────────────────────────────────────────────
1122
+
1123
+ return (
1124
+ <>
1125
+ <Sheet open={open} onOpenChange={onOpenChange}>
1126
+ <SheetContent
1127
+ side="right"
1128
+ className="flex w-full flex-col overflow-y-auto sm:max-w-2xl"
1129
+ >
1130
+ <SheetHeader className="shrink-0">
1131
+ <SheetTitle>
1132
+ {sheetTitle ??
1133
+ (classId ? t('form.title.edit') : t('form.title.create'))}
1134
+ </SheetTitle>
1135
+ <SheetDescription>
1136
+ {sheetDescription ?? t('form.description')}
1137
+ </SheetDescription>
1138
+ </SheetHeader>
1139
+
1140
+ {loading ? (
1141
+ <div className="flex flex-1 items-center justify-center">
1142
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
1143
+ </div>
1144
+ ) : (
1145
+ <form
1146
+ onSubmit={form.handleSubmit(onSubmit)}
1147
+ className="flex flex-1 flex-col gap-5 px-4 py-6"
1148
+ >
1149
+ <div className="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
1150
+ <Field>
1151
+ <FieldLabel htmlFor="codigo">
1152
+ {t('form.fields.code.label')}
1153
+ </FieldLabel>
1154
+ <Input
1155
+ id="codigo"
1156
+ value={watchedFormValues.codigo ?? ''}
1157
+ readOnly
1158
+ className="uppercase"
1159
+ />
1160
+ <FieldDescription>
1161
+ Codigo gerado automaticamente pelo sistema.
1162
+ </FieldDescription>
1163
+ <FieldError>
1164
+ {form.formState.errors.codigo?.message}
1165
+ </FieldError>
1166
+ </Field>
1167
+
1168
+ <Field>
1169
+ <FieldLabel>
1170
+ {t('form.fields.course.label')}{' '}
1171
+ <span className="text-destructive">*</span>
1172
+ </FieldLabel>
1173
+ <div className="flex items-end gap-2">
1174
+ <div className="min-w-0 flex-1">
1175
+ <EntityPicker<{ id: number; title: string }, TurmaForm>
1176
+ form={form}
1177
+ name="courseId"
1178
+ valueType="number"
1179
+ placeholder={t('form.fields.course.placeholder')}
1180
+ entityLabel={t('form.fields.course.label')}
1181
+ initialSelectedLabel={selectedCourseTitle}
1182
+ searchPlaceholder={t('form.fields.course.placeholder')}
1183
+ emptyStateDescription="Nenhum curso encontrado."
1184
+ loadingLabel="Carregando cursos..."
1185
+ noResultsLabel="Nenhum curso encontrado."
1186
+ showCreateButton={false}
1187
+ onChange={(value, option) => {
1188
+ const cId =
1189
+ typeof value === 'number' ? value : undefined;
1190
+ const cTitle =
1191
+ option && typeof option.title === 'string'
1192
+ ? option.title
1193
+ : '';
1194
+ form.setValue('courseId', cId, {
1195
+ shouldDirty: true,
1196
+ shouldTouch: true,
1197
+ shouldValidate: true,
1198
+ });
1199
+ form.setValue('curso', cTitle, {
1200
+ shouldDirty: true,
1201
+ shouldTouch: true,
1202
+ shouldValidate: false,
1203
+ });
1204
+ }}
1205
+ loadOptions={async ({ page, pageSize, search }) => {
1206
+ const response = await request<ApiCourseList>({
1207
+ url: '/lms/courses',
1208
+ method: 'GET',
1209
+ params: {
1210
+ page,
1211
+ pageSize,
1212
+ ...(search.trim()
1213
+ ? { search: search.trim() }
1214
+ : {}),
1215
+ },
1216
+ });
1217
+ return {
1218
+ items: response.data?.data ?? [],
1219
+ hasMore: page < (response.data?.lastPage ?? 1),
1220
+ };
1221
+ }}
1222
+ getOptionValue={(option) => option.id}
1223
+ getOptionLabel={(option) => option.title}
1224
+ />
1225
+ </div>
1226
+ <Button
1227
+ type="button"
1228
+ variant="outline"
1229
+ size="icon"
1230
+ className="shrink-0"
1231
+ onClick={() => {
1232
+ courseForm.reset(DEFAULT_COURSE_FORM_VALUES);
1233
+ setCourseSheetOpen(true);
1234
+ }}
1235
+ aria-label="Cadastrar novo curso"
1236
+ >
1237
+ <Plus className="h-4 w-4" />
1238
+ </Button>
1239
+ </div>
1240
+ </Field>
1241
+ </div>
1242
+
1243
+ <div className="rounded-lg border border-border/70 p-4">
1244
+ <div className="grid gap-4">
1245
+ <Field>
1246
+ <FieldLabel>
1247
+ {t('form.fields.startDate.label')} /{' '}
1248
+ {t('form.fields.endDate.label')}{' '}
1249
+ <span className="text-destructive">*</span>
1250
+ </FieldLabel>
1251
+ <Popover
1252
+ open={dateRangeOpen}
1253
+ onOpenChange={setDateRangeOpen}
1254
+ >
1255
+ <PopoverTrigger asChild>
1256
+ <Button
1257
+ type="button"
1258
+ variant="outline"
1259
+ className={`w-full justify-start text-left font-normal ${
1260
+ !watchedFormValues.dataInicio ||
1261
+ !watchedFormValues.dataFim
1262
+ ? 'text-muted-foreground'
1263
+ : ''
1264
+ }`}
1265
+ >
1266
+ <CalendarIcon className="mr-2 size-4" />
1267
+ {formatDateRangeLabel(
1268
+ watchedFormValues.dataInicio,
1269
+ watchedFormValues.dataFim
1270
+ ) || t('form.fields.startDate.placeholder')}
1271
+ </Button>
1272
+ </PopoverTrigger>
1273
+ <PopoverContent className="w-auto p-0" align="start">
1274
+ <Calendar
1275
+ mode="range"
1276
+ numberOfMonths={2}
1277
+ selected={dateRangeDraft}
1278
+ onSelect={(range) => {
1279
+ setDateRangeDraft(range);
1280
+ if (!range?.from || !range?.to) {
1281
+ form.setValue('dataInicio', '', {
1282
+ shouldDirty: true,
1283
+ shouldTouch: true,
1284
+ shouldValidate: true,
1285
+ });
1286
+ form.setValue('dataFim', '', {
1287
+ shouldDirty: true,
1288
+ shouldTouch: true,
1289
+ shouldValidate: true,
1290
+ });
1291
+ return;
1292
+ }
1293
+ form.setValue(
1294
+ 'dataInicio',
1295
+ format(range.from, 'yyyy-MM-dd'),
1296
+ {
1297
+ shouldDirty: true,
1298
+ shouldTouch: true,
1299
+ shouldValidate: true,
1300
+ }
1301
+ );
1302
+ form.setValue(
1303
+ 'dataFim',
1304
+ format(range.to, 'yyyy-MM-dd'),
1305
+ {
1306
+ shouldDirty: true,
1307
+ shouldTouch: true,
1308
+ shouldValidate: true,
1309
+ }
1310
+ );
1311
+ }}
1312
+ initialFocus
1313
+ />
1314
+ </PopoverContent>
1315
+ </Popover>
1316
+ <FieldError>
1317
+ {form.formState.errors.dataInicio?.message ||
1318
+ form.formState.errors.dataFim?.message}
1319
+ </FieldError>
1320
+ </Field>
1321
+
1322
+ <div className="grid gap-4 md:grid-cols-2">
1323
+ <Field>
1324
+ <FieldLabel htmlFor="horarioInicio">
1325
+ {t('form.fields.startTime.label')}{' '}
1326
+ <span className="text-destructive">*</span>
1327
+ </FieldLabel>
1328
+ <Controller
1329
+ name="horarioInicio"
1330
+ control={form.control}
1331
+ render={({ field }) => (
1332
+ <Select
1333
+ onValueChange={field.onChange}
1334
+ value={field.value}
1335
+ >
1336
+ <SelectTrigger id="horarioInicio">
1337
+ <SelectValue
1338
+ placeholder={t(
1339
+ 'form.fields.startTime.placeholder'
1340
+ )}
1341
+ />
1342
+ </SelectTrigger>
1343
+ <SelectContent>
1344
+ {TIME_OPTIONS.map((time) => (
1345
+ <SelectItem key={time} value={time}>
1346
+ {time}
1347
+ </SelectItem>
1348
+ ))}
1349
+ </SelectContent>
1350
+ </Select>
1351
+ )}
1352
+ />
1353
+ <FieldDescription>
1354
+ Escolha o horario de inicio na lista para preencher mais
1355
+ rapido.
1356
+ </FieldDescription>
1357
+ <FieldError>
1358
+ {form.formState.errors.horarioInicio?.message}
1359
+ </FieldError>
1360
+ </Field>
1361
+
1362
+ <Field>
1363
+ <FieldLabel htmlFor="horarioFim">
1364
+ {t('form.fields.endTime.label')}{' '}
1365
+ <span className="text-destructive">*</span>
1366
+ </FieldLabel>
1367
+ <Controller
1368
+ name="horarioFim"
1369
+ control={form.control}
1370
+ render={({ field }) => (
1371
+ <Select
1372
+ onValueChange={field.onChange}
1373
+ value={field.value}
1374
+ >
1375
+ <SelectTrigger id="horarioFim">
1376
+ <SelectValue
1377
+ placeholder={t(
1378
+ 'form.fields.endTime.placeholder'
1379
+ )}
1380
+ />
1381
+ </SelectTrigger>
1382
+ <SelectContent>
1383
+ {filteredEndTimeOptions.map((time) => (
1384
+ <SelectItem key={time} value={time}>
1385
+ {time}
1386
+ </SelectItem>
1387
+ ))}
1388
+ </SelectContent>
1389
+ </Select>
1390
+ )}
1391
+ />
1392
+ <FieldDescription>
1393
+ O termino mostra apenas horarios iguais ou depois do
1394
+ inicio.
1395
+ </FieldDescription>
1396
+ <FieldError>
1397
+ {form.formState.errors.horarioFim?.message}
1398
+ </FieldError>
1399
+ </Field>
1400
+ </div>
1401
+ </div>
1402
+ </div>
1403
+
1404
+ <div className="rounded-lg border border-border/70 p-4">
1405
+ <div className="space-y-4">
1406
+ <div className="space-y-1">
1407
+ <h3 className="text-sm font-semibold">
1408
+ {t('form.recurrence.sectionTitle')}
1409
+ </h3>
1410
+ <p className="text-sm text-muted-foreground">
1411
+ {t('form.recurrence.sectionDescription')}
1412
+ </p>
1413
+ </div>
1414
+
1415
+ <Field>
1416
+ <FieldLabel>{t('form.recurrence.label')}</FieldLabel>
1417
+ <Select
1418
+ value={watchedFormValues.sessionRecurrenceMode}
1419
+ onValueChange={(value) =>
1420
+ handleRecurrenceModeChange(
1421
+ value as SessionRecurrenceMode
1422
+ )
1423
+ }
1424
+ >
1425
+ <SelectTrigger className="w-full md:w-56">
1426
+ <SelectValue />
1427
+ </SelectTrigger>
1428
+ <SelectContent>
1429
+ <SelectItem value="none">
1430
+ {t('form.recurrence.options.none')}
1431
+ </SelectItem>
1432
+ <SelectItem value="daily">
1433
+ {t('form.recurrence.options.daily')}
1434
+ </SelectItem>
1435
+ <SelectItem value="weekly">
1436
+ {t('form.recurrence.options.weekly')}
1437
+ </SelectItem>
1438
+ <SelectItem value="monthly">
1439
+ {t('form.recurrence.options.monthly')}
1440
+ </SelectItem>
1441
+ <SelectItem value="yearly">
1442
+ {t('form.recurrence.options.yearly')}
1443
+ </SelectItem>
1444
+ <SelectItem value="weekdays">
1445
+ {t('form.recurrence.options.weekdays')}
1446
+ </SelectItem>
1447
+ <SelectItem value="custom">
1448
+ {t('form.recurrence.options.custom')}
1449
+ </SelectItem>
1450
+ </SelectContent>
1451
+ </Select>
1452
+ <FieldError>
1453
+ {form.formState.errors.sessionRecurrenceMode?.message}
1454
+ </FieldError>
1455
+ </Field>
1456
+
1457
+ {(watchedFormValues.sessionRecurrenceMode === 'weekly' ||
1458
+ watchedFormValues.sessionRecurrenceMode === 'weekdays' ||
1459
+ (watchedFormValues.sessionRecurrenceMode === 'custom' &&
1460
+ customRecurrenceFrequency === 'weekly')) && (
1461
+ <Field>
1462
+ <FieldLabel>
1463
+ {t('form.recurrence.customDialog.repeatOn')}
1464
+ </FieldLabel>
1465
+ <div className="flex flex-wrap gap-2">
1466
+ {recurrenceDayOptions.map((day) => {
1467
+ const active = (
1468
+ watchedFormValues.sessionRecurrenceDaysOfWeek ?? []
1469
+ ).includes(day.value);
1470
+ const isWeekdays =
1471
+ watchedFormValues.sessionRecurrenceMode ===
1472
+ 'weekdays';
1473
+ return (
1474
+ <Button
1475
+ key={day.value}
1476
+ type="button"
1477
+ variant={active ? 'default' : 'outline'}
1478
+ size="icon"
1479
+ className="rounded-full"
1480
+ disabled={isWeekdays}
1481
+ onClick={() =>
1482
+ toggleCustomRecurrenceDay(day.value)
1483
+ }
1484
+ >
1485
+ {day.label}
1486
+ </Button>
1487
+ );
1488
+ })}
1489
+ </div>
1490
+ <FieldError>
1491
+ {
1492
+ form.formState.errors.sessionRecurrenceDaysOfWeek
1493
+ ?.message
1494
+ }
1495
+ </FieldError>
1496
+ </Field>
1497
+ )}
1498
+
1499
+ <div className="grid gap-4 md:grid-cols-[minmax(0,0.55fr)_minmax(0,1fr)]">
1500
+ <Field>
1501
+ <FieldLabel>
1502
+ {t('form.recurrence.titleMode.label')}
1503
+ </FieldLabel>
1504
+ <Controller
1505
+ name="sessionTitleMode"
1506
+ control={form.control}
1507
+ render={({ field }) => (
1508
+ <Select
1509
+ value={field.value}
1510
+ onValueChange={(value) => {
1511
+ field.onChange(value);
1512
+ if (value === 'default-course-code') {
1513
+ form.setValue(
1514
+ 'sessionTitle',
1515
+ defaultSessionTitle,
1516
+ {
1517
+ shouldDirty: true,
1518
+ shouldTouch: false,
1519
+ shouldValidate: false,
1520
+ }
1521
+ );
1522
+ }
1523
+ }}
1524
+ >
1525
+ <SelectTrigger>
1526
+ <SelectValue />
1527
+ </SelectTrigger>
1528
+ <SelectContent>
1529
+ <SelectItem value="default-course-code">
1530
+ {t('form.recurrence.titleMode.default')}
1531
+ </SelectItem>
1532
+ <SelectItem value="custom">
1533
+ {t('form.recurrence.titleMode.custom')}
1534
+ </SelectItem>
1535
+ </SelectContent>
1536
+ </Select>
1537
+ )}
1538
+ />
1539
+ </Field>
1540
+
1541
+ <Field>
1542
+ <FieldLabel htmlFor="sessionTitle">
1543
+ {t('form.fields.sessionTitle.label')}
1544
+ </FieldLabel>
1545
+ <Input
1546
+ id="sessionTitle"
1547
+ value={
1548
+ watchedFormValues.sessionTitleMode ===
1549
+ 'default-course-code'
1550
+ ? defaultSessionTitle
1551
+ : (watchedFormValues.sessionTitle ?? '')
1552
+ }
1553
+ placeholder={t('form.fields.sessionTitle.placeholder')}
1554
+ disabled={
1555
+ watchedFormValues.sessionTitleMode ===
1556
+ 'default-course-code'
1557
+ }
1558
+ onChange={(event) =>
1559
+ form.setValue('sessionTitle', event.target.value, {
1560
+ shouldDirty: true,
1561
+ shouldTouch: true,
1562
+ shouldValidate: false,
1563
+ })
1564
+ }
1565
+ />
1566
+ <FieldDescription>
1567
+ {watchedFormValues.sessionTitleMode ===
1568
+ 'default-course-code'
1569
+ ? defaultSessionTitle ||
1570
+ t('form.fields.sessionTitle.placeholder')
1571
+ : recurrenceSummaryText}
1572
+ </FieldDescription>
1573
+ <FieldError>
1574
+ {form.formState.errors.sessionTitle?.message}
1575
+ </FieldError>
1576
+ </Field>
1577
+ </div>
1578
+ </div>
1579
+ </div>
1580
+
1581
+ <div className="grid gap-4 md:grid-cols-3">
1582
+ <Field>
1583
+ <FieldLabel>
1584
+ {t('form.fields.type.label')}{' '}
1585
+ <span className="text-destructive">*</span>
1586
+ </FieldLabel>
1587
+ <Controller
1588
+ name="tipo"
1589
+ control={form.control}
1590
+ render={({ field }) => (
1591
+ <Select
1592
+ onValueChange={field.onChange}
1593
+ value={field.value}
1594
+ >
1595
+ <SelectTrigger>
1596
+ <SelectValue />
1597
+ </SelectTrigger>
1598
+ <SelectContent>
1599
+ <SelectItem value="online">
1600
+ {t('type.online')}
1601
+ </SelectItem>
1602
+ <SelectItem value="presencial">
1603
+ {t('type.inPerson')}
1604
+ </SelectItem>
1605
+ <SelectItem value="hibrida">
1606
+ {t('type.hybrid')}
1607
+ </SelectItem>
1608
+ </SelectContent>
1609
+ </Select>
1610
+ )}
1611
+ />
1612
+ <FieldError>{form.formState.errors.tipo?.message}</FieldError>
1613
+ </Field>
1614
+
1615
+ <Field>
1616
+ <FieldLabel>
1617
+ {t('form.fields.status.label')}{' '}
1618
+ <span className="text-destructive">*</span>
1619
+ </FieldLabel>
1620
+ <Controller
1621
+ name="status"
1622
+ control={form.control}
1623
+ render={({ field }) => (
1624
+ <Select
1625
+ onValueChange={field.onChange}
1626
+ value={field.value}
1627
+ >
1628
+ <SelectTrigger>
1629
+ <SelectValue />
1630
+ </SelectTrigger>
1631
+ <SelectContent>
1632
+ <SelectItem value="aberta">
1633
+ {t('status.open')}
1634
+ </SelectItem>
1635
+ <SelectItem value="em_andamento">
1636
+ {t('status.inProgress')}
1637
+ </SelectItem>
1638
+ <SelectItem value="concluida">
1639
+ {t('status.completed')}
1640
+ </SelectItem>
1641
+ <SelectItem value="cancelada">
1642
+ {t('status.cancelled')}
1643
+ </SelectItem>
1644
+ </SelectContent>
1645
+ </Select>
1646
+ )}
1647
+ />
1648
+ <FieldError>
1649
+ {form.formState.errors.status?.message}
1650
+ </FieldError>
1651
+ </Field>
1652
+
1653
+ <Field>
1654
+ <FieldLabel htmlFor="vagas">
1655
+ {t('form.fields.vacancies.label')}{' '}
1656
+ <span className="text-destructive">*</span>
1657
+ </FieldLabel>
1658
+ <Input
1659
+ id="vagas"
1660
+ type="number"
1661
+ min={1}
1662
+ {...form.register('vagas')}
1663
+ />
1664
+ <FieldError>
1665
+ {form.formState.errors.vagas?.message}
1666
+ </FieldError>
1667
+ </Field>
1668
+ </div>
1669
+
1670
+ <Field>
1671
+ <FieldLabel>
1672
+ {t('form.fields.professor.label')}{' '}
1673
+ <span className="text-destructive">*</span>
1674
+ </FieldLabel>
1675
+ <Controller
1676
+ name="professor"
1677
+ control={form.control}
1678
+ render={({ field }) => (
1679
+ <div className="flex items-end gap-2">
1680
+ <div className="flex-1">
1681
+ <Popover
1682
+ open={professorOpen}
1683
+ onOpenChange={setProfessorOpen}
1684
+ >
1685
+ <PopoverTrigger asChild>
1686
+ <Button
1687
+ type="button"
1688
+ variant="outline"
1689
+ role="combobox"
1690
+ className="w-full justify-between"
1691
+ >
1692
+ <span className="truncate text-left">
1693
+ {field.value ||
1694
+ t('form.fields.professor.placeholder')}
1695
+ </span>
1696
+ {loadingProfessores ? (
1697
+ <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-60" />
1698
+ ) : (
1699
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
1700
+ )}
1701
+ </Button>
1702
+ </PopoverTrigger>
1703
+ <PopoverContent className="p-0" align="start">
1704
+ <Command shouldFilter={false}>
1705
+ <CommandInput
1706
+ placeholder={t(
1707
+ 'form.fields.professor.placeholder'
1708
+ )}
1709
+ value={professorSearch}
1710
+ onValueChange={setProfessorSearch}
1711
+ />
1712
+ <CommandList>
1713
+ <CommandEmpty>
1714
+ <div className="flex flex-col items-center gap-3 px-2 py-4">
1715
+ <p className="text-sm text-muted-foreground">
1716
+ Nenhum professor encontrado.
1717
+ </p>
1718
+ <Button
1719
+ type="button"
1720
+ variant="outline"
1721
+ size="sm"
1722
+ className="w-full"
1723
+ onClick={() => {
1724
+ setProfessorOpen(false);
1725
+ setCreateProfessorDialogOpen(true);
1726
+ }}
1727
+ >
1728
+ <Plus className="mr-2 h-4 w-4" />
1729
+ Cadastrar novo professor
1730
+ </Button>
1731
+ </div>
1732
+ </CommandEmpty>
1733
+ <CommandGroup>
1734
+ {professorOptions.map((professor) => (
1735
+ <CommandItem
1736
+ key={professor.id}
1737
+ value={`${professor.name}-${professor.id}`}
1738
+ onSelect={() => {
1739
+ form.setValue(
1740
+ 'instructorId',
1741
+ professor.id,
1742
+ {
1743
+ shouldDirty: true,
1744
+ shouldTouch: true,
1745
+ shouldValidate: true,
1746
+ }
1747
+ );
1748
+ field.onChange(professor.name);
1749
+ setProfessorOpen(false);
1750
+ setProfessorSearch('');
1751
+ }}
1752
+ >
1753
+ {professor.name}
1754
+ </CommandItem>
1755
+ ))}
1756
+ </CommandGroup>
1757
+ </CommandList>
1758
+ </Command>
1759
+ </PopoverContent>
1760
+ </Popover>
1761
+ </div>
1762
+ <Button
1763
+ type="button"
1764
+ variant="outline"
1765
+ size="icon"
1766
+ className="shrink-0"
1767
+ onClick={() => setCreateProfessorDialogOpen(true)}
1768
+ aria-label="Cadastrar novo professor"
1769
+ >
1770
+ <Plus className="h-4 w-4" />
1771
+ </Button>
1772
+ </div>
1773
+ )}
1774
+ />
1775
+ <FieldError>
1776
+ {form.formState.errors.professor?.message}
1777
+ </FieldError>
1778
+ </Field>
1779
+
1780
+ <SheetFooter className="mt-auto shrink-0 gap-2 px-0 pt-4">
1781
+ <Button type="submit" disabled={saving} className="gap-2">
1782
+ {saving && <Loader2 className="size-4 animate-spin" />}
1783
+ {classId ? t('form.actions.save') : t('form.actions.create')}
1784
+ </Button>
1785
+ </SheetFooter>
1786
+ </form>
1787
+ )}
1788
+ </SheetContent>
1789
+ </Sheet>
1790
+
1791
+ {/* Custom Recurrence Dialog */}
1792
+ <Dialog
1793
+ open={customRecurrenceDialogOpen}
1794
+ onOpenChange={(isOpen) => {
1795
+ if (!isOpen) {
1796
+ if (customRecurrenceConfirmedRef.current) {
1797
+ customRecurrenceConfirmedRef.current = false;
1798
+ setCustomRecurrenceDialogOpen(false);
1799
+ return;
1800
+ }
1801
+ handleCustomRecurrenceCancel();
1802
+ return;
1803
+ }
1804
+ setCustomRecurrenceDialogOpen(true);
1805
+ }}
1806
+ >
1807
+ <DialogContent className="sm:max-w-xl">
1808
+ <DialogHeader>
1809
+ <DialogTitle>{t('form.recurrence.customDialog.title')}</DialogTitle>
1810
+ <DialogDescription>
1811
+ {t('form.recurrence.customDialog.description')}
1812
+ </DialogDescription>
1813
+ </DialogHeader>
1814
+
1815
+ <div className="grid gap-5 py-2">
1816
+ <div className="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)] sm:items-end">
1817
+ <Field>
1818
+ <FieldLabel>
1819
+ {t('form.recurrence.customDialog.repeatEvery')}
1820
+ </FieldLabel>
1821
+ <Input
1822
+ type="number"
1823
+ min={1}
1824
+ value={watchedFormValues.sessionRecurrenceInterval ?? 1}
1825
+ onChange={(event) =>
1826
+ form.setValue(
1827
+ 'sessionRecurrenceInterval',
1828
+ Number(event.target.value) || 1,
1829
+ {
1830
+ shouldDirty: true,
1831
+ shouldTouch: true,
1832
+ shouldValidate: true,
1833
+ }
1834
+ )
1835
+ }
1836
+ />
1837
+ <FieldError>
1838
+ {form.formState.errors.sessionRecurrenceInterval?.message}
1839
+ </FieldError>
1840
+ </Field>
1841
+
1842
+ <Field>
1843
+ <FieldLabel>&nbsp;</FieldLabel>
1844
+ <Controller
1845
+ name="sessionRecurrenceCustomFrequency"
1846
+ control={form.control}
1847
+ render={({ field }) => (
1848
+ <Select
1849
+ value={field.value}
1850
+ onValueChange={(value) => {
1851
+ field.onChange(value);
1852
+ if (
1853
+ value === 'weekly' &&
1854
+ (watchedFormValues.sessionRecurrenceDaysOfWeek ?? [])
1855
+ .length === 0 &&
1856
+ watchedFormValues.dataInicio
1857
+ ) {
1858
+ form.setValue(
1859
+ 'sessionRecurrenceDaysOfWeek',
1860
+ [getDayCodeFromDate(watchedFormValues.dataInicio)],
1861
+ {
1862
+ shouldDirty: true,
1863
+ shouldTouch: false,
1864
+ shouldValidate: true,
1865
+ }
1866
+ );
1867
+ }
1868
+ }}
1869
+ >
1870
+ <SelectTrigger>
1871
+ <SelectValue />
1872
+ </SelectTrigger>
1873
+ <SelectContent>
1874
+ <SelectItem value="daily">
1875
+ {t('form.recurrence.customDialog.frequency.daily')}
1876
+ </SelectItem>
1877
+ <SelectItem value="weekly">
1878
+ {t('form.recurrence.customDialog.frequency.weekly')}
1879
+ </SelectItem>
1880
+ <SelectItem value="monthly">
1881
+ {t('form.recurrence.customDialog.frequency.monthly')}
1882
+ </SelectItem>
1883
+ <SelectItem value="yearly">
1884
+ {t('form.recurrence.customDialog.frequency.yearly')}
1885
+ </SelectItem>
1886
+ </SelectContent>
1887
+ </Select>
1888
+ )}
1889
+ />
1890
+ </Field>
1891
+ </div>
1892
+
1893
+ {customRecurrenceNeedsWeekdays && (
1894
+ <Field>
1895
+ <FieldLabel>
1896
+ {t('form.recurrence.customDialog.repeatOn')}
1897
+ </FieldLabel>
1898
+ <div className="flex flex-wrap gap-2">
1899
+ {recurrenceDayOptions.map((day) => {
1900
+ const active = (
1901
+ watchedFormValues.sessionRecurrenceDaysOfWeek ?? []
1902
+ ).includes(day.value);
1903
+ return (
1904
+ <Button
1905
+ key={day.value}
1906
+ type="button"
1907
+ variant={active ? 'default' : 'outline'}
1908
+ size="icon"
1909
+ className="rounded-full"
1910
+ onClick={() => toggleCustomRecurrenceDay(day.value)}
1911
+ >
1912
+ {day.label}
1913
+ </Button>
1914
+ );
1915
+ })}
1916
+ </div>
1917
+ <FieldError>
1918
+ {form.formState.errors.sessionRecurrenceDaysOfWeek?.message}
1919
+ </FieldError>
1920
+ </Field>
1921
+ )}
1922
+
1923
+ <Field>
1924
+ <FieldLabel>
1925
+ {t('form.recurrence.customDialog.endDate')}
1926
+ </FieldLabel>
1927
+ <Input
1928
+ type="date"
1929
+ min={watchedFormValues.dataInicio || undefined}
1930
+ value={watchedFormValues.sessionRecurrenceUntil ?? ''}
1931
+ onChange={(event) =>
1932
+ form.setValue('sessionRecurrenceUntil', event.target.value, {
1933
+ shouldDirty: true,
1934
+ shouldTouch: true,
1935
+ shouldValidate: true,
1936
+ })
1937
+ }
1938
+ />
1939
+ <FieldError>
1940
+ {form.formState.errors.sessionRecurrenceUntil?.message}
1941
+ </FieldError>
1942
+ </Field>
1943
+ </div>
1944
+
1945
+ <DialogFooter className="gap-2">
1946
+ <Button
1947
+ type="button"
1948
+ variant="outline"
1949
+ onClick={handleCustomRecurrenceCancel}
1950
+ >
1951
+ {t('form.recurrence.customDialog.cancel')}
1952
+ </Button>
1953
+ <Button type="button" onClick={handleCustomRecurrenceConfirm}>
1954
+ {t('form.recurrence.customDialog.confirm')}
1955
+ </Button>
1956
+ </DialogFooter>
1957
+ </DialogContent>
1958
+ </Dialog>
1959
+
1960
+ {/* Course create sheet */}
1961
+ <CourseFormSheet
1962
+ key="class-form-sheet-course-create"
1963
+ open={courseSheetOpen}
1964
+ onOpenChange={setCourseSheetOpen}
1965
+ editing={false}
1966
+ saving={savingCourse}
1967
+ form={courseForm}
1968
+ onSubmit={onSubmitCourse}
1969
+ categories={categoryOptions}
1970
+ onCreateCategory={() => {
1971
+ if (typeof window !== 'undefined') {
1972
+ window.open('/category?new=1', '_blank');
1973
+ }
1974
+ }}
1975
+ t={courseSheetT}
1976
+ />
1977
+
1978
+ {/* Professor create sheet */}
1979
+ <CreateLmsPersonSheet
1980
+ open={createProfessorDialogOpen}
1981
+ onOpenChange={setCreateProfessorDialogOpen}
1982
+ onCreated={handleProfessorCreated}
1983
+ title="Cadastrar novo professor"
1984
+ description="Crie um novo professor para seleciona-lo nesta turma."
1985
+ submitLabel="Cadastrar"
1986
+ successMessage="Professor cadastrado com sucesso."
1987
+ errorMessage="Não foi possível cadastrar o professor."
1988
+ defaultQualificationSlugs={['class-sessions']}
1989
+ />
1990
+ </>
1991
+ );
1992
+ }