@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
@@ -5,10 +5,21 @@ import {
5
5
  Page,
6
6
  PageHeader,
7
7
  PaginationFooter,
8
+ SearchBar,
9
+ ViewModeToggle,
8
10
  } from '@/components/entity-list';
9
11
  import { Badge } from '@/components/ui/badge';
10
12
  import { Button } from '@/components/ui/button';
13
+ import { Calendar } from '@/components/ui/calendar';
11
14
  import { Card, CardContent } from '@/components/ui/card';
15
+ import {
16
+ Command,
17
+ CommandEmpty,
18
+ CommandGroup,
19
+ CommandInput,
20
+ CommandItem,
21
+ CommandList,
22
+ } from '@/components/ui/command';
12
23
  import {
13
24
  Dialog,
14
25
  DialogContent,
@@ -24,8 +35,20 @@ import {
24
35
  DropdownMenuSeparator,
25
36
  DropdownMenuTrigger,
26
37
  } from '@/components/ui/dropdown-menu';
27
- import { Field, FieldError, FieldLabel } from '@/components/ui/field';
38
+ import { EntityPicker } from '@/components/ui/entity-picker';
39
+ import {
40
+ Field,
41
+ FieldDescription,
42
+ FieldError,
43
+ FieldLabel,
44
+ } from '@/components/ui/field';
28
45
  import { Input } from '@/components/ui/input';
46
+ import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
47
+ import {
48
+ Popover,
49
+ PopoverContent,
50
+ PopoverTrigger,
51
+ } from '@/components/ui/popover';
29
52
  import {
30
53
  Select,
31
54
  SelectContent,
@@ -42,12 +65,25 @@ import {
42
65
  SheetTitle,
43
66
  } from '@/components/ui/sheet';
44
67
  import { Skeleton } from '@/components/ui/skeleton';
68
+ import {
69
+ Table,
70
+ TableBody,
71
+ TableCell,
72
+ TableHead,
73
+ TableHeader,
74
+ TableRow,
75
+ } from '@/components/ui/table';
76
+ import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
77
+ import { formatDate as formatDateLocalized } from '@/lib/format-date';
78
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
79
  import { zodResolver } from '@hookform/resolvers/zod';
80
+ import { format } from 'date-fns';
46
81
  import { motion } from 'framer-motion';
47
82
  import {
48
83
  AlertTriangle,
49
84
  BarChart3,
50
- Calendar,
85
+ CalendarIcon,
86
+ ChevronsUpDown,
51
87
  Clock,
52
88
  Eye,
53
89
  Laptop,
@@ -57,18 +93,27 @@ import {
57
93
  MoreHorizontal,
58
94
  Pencil,
59
95
  Plus,
60
- Search,
61
96
  Trash2,
62
97
  Users,
63
98
  Users2,
64
99
  X,
100
+ type LucideIcon,
65
101
  } from 'lucide-react';
66
102
  import { useTranslations } from 'next-intl';
67
- import { usePathname, useRouter } from 'next/navigation';
103
+ import { useRouter } from 'next/navigation';
68
104
  import { useEffect, useMemo, useRef, useState } from 'react';
69
- import { Controller, useForm } from 'react-hook-form';
105
+ import type { DateRange } from 'react-day-picker';
106
+ import { Controller, useForm, useWatch } from 'react-hook-form';
70
107
  import { toast } from 'sonner';
71
108
  import { z } from 'zod';
109
+ import {
110
+ CourseFormSheet,
111
+ DEFAULT_COURSE_FORM_VALUES,
112
+ getCourseSheetSchema,
113
+ type CourseCategoryOption,
114
+ type CourseSheetFormValues,
115
+ } from '../_components/course-form-sheet';
116
+ import { CreateLmsPersonSheet } from '../_components/create-lms-person-sheet';
72
117
 
73
118
  // ── Types ─────────────────────────────────────────────────────────────────────
74
119
 
@@ -77,58 +122,424 @@ interface Turma {
77
122
  codigo: string;
78
123
  curso: string;
79
124
  cursoId: number;
125
+ instructorId?: number | null;
126
+ primaryColor?: string | null;
80
127
  tipo: 'presencial' | 'online' | 'hibrida';
81
128
  dataInicio: string;
82
129
  dataFim: string;
83
- horario: string;
130
+ horarioInicio: string;
131
+ horarioFim: string;
84
132
  status: 'aberta' | 'em_andamento' | 'concluida' | 'cancelada';
85
133
  vagas: number;
86
134
  matriculados: number;
87
135
  professor: string;
136
+ sessionTitle?: string | null;
137
+ sessionRecurrenceSummary?: SessionRecurrenceSummary | null;
138
+ }
139
+
140
+ type SessionRecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
141
+ type SessionRecurrenceMode =
142
+ | 'none'
143
+ | 'daily'
144
+ | 'weekly'
145
+ | 'monthly'
146
+ | 'yearly'
147
+ | 'weekdays'
148
+ | 'custom';
149
+ type SessionRecurrenceDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
150
+
151
+ type SessionRecurrenceSummary = {
152
+ frequency?: SessionRecurrenceFrequency;
153
+ interval?: number;
154
+ until?: string;
155
+ daysOfWeek?: SessionRecurrenceDay[];
156
+ isRecurring: boolean;
157
+ };
158
+
159
+ type ApiClass = {
160
+ id: number;
161
+ code: string;
162
+ title: string;
163
+ primaryColor?: string | null;
164
+ deliveryMode: 'presential' | 'online' | 'hybrid';
165
+ status: 'open' | 'ongoing' | 'completed' | 'cancelled';
166
+ startDate: string;
167
+ endDate: string | null;
168
+ startTime: string | null;
169
+ endTime: string | null;
170
+ capacity: number;
171
+ courseId: number;
172
+ instructorId?: number | null;
173
+ courseTitle: string;
174
+ enrolledCount: number;
175
+ professor?: string | null;
176
+ professorName?: string | null;
177
+ instructor?: string | null;
178
+ instructorName?: string | null;
179
+ sessionTitle?: string | null;
180
+ sessionRecurrenceSummary?: SessionRecurrenceSummary | null;
181
+ };
182
+
183
+ type ApiClassList = {
184
+ data: ApiClass[];
185
+ total: number;
186
+ page: number;
187
+ pageSize: number;
188
+ lastPage: number;
189
+ };
190
+
191
+ type ApiClassStats = {
192
+ totalClasses: number;
193
+ ongoingClasses: number;
194
+ openVacancies: number;
195
+ occupancyRate: number;
196
+ };
197
+
198
+ type ApiCourseList = {
199
+ data: Array<{ id: number; title: string }>;
200
+ total?: number;
201
+ page?: number;
202
+ pageSize?: number;
203
+ lastPage?: number;
204
+ };
205
+
206
+ type ApiCategory = {
207
+ id: number;
208
+ slug: string;
209
+ name: string;
210
+ status?: 'active' | 'inactive';
211
+ };
212
+
213
+ type ApiCategoryList = {
214
+ data: ApiCategory[];
215
+ total: number;
216
+ page: number;
217
+ pageSize: number;
218
+ };
219
+
220
+ type ApiCreatedCourse = {
221
+ id: number;
222
+ title: string;
223
+ };
224
+
225
+ type InstructorOption = {
226
+ id: number;
227
+ name: string;
228
+ personId?: number;
229
+ qualificationSlugs?: string[];
230
+ };
231
+
232
+ type InstructorApiRow = {
233
+ id?: number | string;
234
+ instructor_id?: number | string;
235
+ value?: number | string;
236
+ name?: string;
237
+ nome?: string;
238
+ full_name?: string;
239
+ label?: string;
240
+ personId?: number | string;
241
+ person_id?: number | string;
242
+ qualificationSlugs?: string[];
243
+ };
244
+
245
+ type Locale = {
246
+ id?: number;
247
+ code: string;
248
+ name: string;
249
+ };
250
+
251
+ function normalizeInstructorOption(
252
+ item: InstructorApiRow
253
+ ): InstructorOption | null {
254
+ const id = Number(item?.id ?? item?.instructor_id ?? item?.value ?? 0);
255
+ const name = String(
256
+ item?.name ?? item?.nome ?? item?.full_name ?? item?.label ?? ''
257
+ ).trim();
258
+
259
+ if (!id || !name) {
260
+ return null;
261
+ }
262
+
263
+ return {
264
+ id,
265
+ name,
266
+ personId: Number(item?.personId ?? item?.person_id ?? 0) || undefined,
267
+ qualificationSlugs: Array.isArray(item?.qualificationSlugs)
268
+ ? item.qualificationSlugs
269
+ : undefined,
270
+ };
271
+ }
272
+
273
+ function getCourseIdByTitle(
274
+ courses: Array<{ id: number; title: string }>,
275
+ title: string
276
+ ) {
277
+ return courses.find((course) => course.title === title)?.id;
278
+ }
279
+
280
+ function toApiCourseLevel(level: CourseSheetFormValues['nivel']) {
281
+ if (level === 'iniciante') return 'beginner';
282
+ if (level === 'intermediario') return 'intermediate';
283
+ return 'advanced';
284
+ }
285
+
286
+ function toApiCourseStatus(status: CourseSheetFormValues['status']) {
287
+ if (status === 'ativo') return 'published';
288
+ if (status === 'rascunho') return 'draft';
289
+ return 'archived';
290
+ }
291
+
292
+ function getContrastColor(hex: string) {
293
+ const cleaned = hex.replace('#', '');
294
+ if (cleaned.length !== 6) return '#FFFFFF';
295
+
296
+ const r = parseInt(cleaned.slice(0, 2), 16);
297
+ const g = parseInt(cleaned.slice(2, 4), 16);
298
+ const b = parseInt(cleaned.slice(4, 6), 16);
299
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
300
+
301
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
302
+ }
303
+
304
+ function parseFormDate(value: string) {
305
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
306
+
307
+ if (!match) {
308
+ return null;
309
+ }
310
+
311
+ return new Date(
312
+ Number(match[1]),
313
+ Number(match[2]) - 1,
314
+ Number(match[3]),
315
+ 12,
316
+ 0,
317
+ 0,
318
+ 0
319
+ );
320
+ }
321
+
322
+ function getDayCodeFromDate(value?: string): SessionRecurrenceDay {
323
+ const date = parseFormDate(value ?? '') ?? new Date();
324
+ return ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][
325
+ date.getDay()
326
+ ] as SessionRecurrenceDay;
327
+ }
328
+
329
+ function getDefaultSessionTitle(courseTitle?: string, code?: string) {
330
+ return [courseTitle?.trim(), code?.trim()].filter(Boolean).join(' - ');
331
+ }
332
+
333
+ function toApiType(tipo: string) {
334
+ if (tipo === 'presencial') return 'presential';
335
+ if (tipo === 'hibrida') return 'hybrid';
336
+ return 'online';
337
+ }
338
+
339
+ function toApiStatus(status: string) {
340
+ if (status === 'aberta') return 'open';
341
+ if (status === 'em_andamento') return 'ongoing';
342
+ if (status === 'concluida') return 'completed';
343
+ return 'cancelled';
344
+ }
345
+
346
+ function toPtType(tipo: ApiClass['deliveryMode']): Turma['tipo'] {
347
+ if (tipo === 'presential') return 'presencial';
348
+ if (tipo === 'hybrid') return 'hibrida';
349
+ return 'online';
350
+ }
351
+
352
+ function toPtStatus(status: ApiClass['status']): Turma['status'] {
353
+ if (status === 'open') return 'aberta';
354
+ if (status === 'ongoing') return 'em_andamento';
355
+ if (status === 'completed') return 'concluida';
356
+ return 'cancelada';
357
+ }
358
+
359
+ function getDateOnly(value?: string | null) {
360
+ if (!value) return '';
361
+ return value.slice(0, 10);
362
+ }
363
+
364
+ function formatSchedule(startTime?: string | null, endTime?: string | null) {
365
+ if (!startTime && !endTime) return '';
366
+ if (!startTime) return endTime ?? '';
367
+ if (!endTime) return startTime;
368
+
369
+ return `${startTime} - ${endTime}`;
370
+ }
371
+
372
+ function mapApiClass(item: ApiClass): Turma {
373
+ const professorName =
374
+ item.professorName ??
375
+ item.professor ??
376
+ item.instructorName ??
377
+ item.instructor;
378
+
379
+ return {
380
+ id: item.id,
381
+ codigo: item.code,
382
+ curso: item.courseTitle,
383
+ cursoId: item.courseId,
384
+ instructorId: item.instructorId ?? null,
385
+ primaryColor: item.primaryColor,
386
+ tipo: toPtType(item.deliveryMode),
387
+ dataInicio: getDateOnly(item.startDate),
388
+ dataFim: getDateOnly(item.endDate),
389
+ horarioInicio: item.startTime ?? '',
390
+ horarioFim: item.endTime ?? '',
391
+ status: toPtStatus(item.status),
392
+ vagas: item.capacity,
393
+ matriculados: item.enrolledCount,
394
+ professor: professorName?.trim() || '-',
395
+ sessionTitle: item.sessionTitle ?? null,
396
+ sessionRecurrenceSummary: item.sessionRecurrenceSummary ?? null,
397
+ };
88
398
  }
89
399
 
90
400
  // ── Schema ────────────────────────────────────────────────────────────────────
91
401
 
92
402
  function getTurmaSchema(t: (key: string) => string) {
93
- return z.object({
94
- codigo: z.string().min(3, t('form.validation.codigoMinLength')),
95
- curso: z.string().min(1, t('form.validation.cursoRequired')),
96
- tipo: z.string().min(1, t('form.validation.tipoRequired')),
97
- professor: z.string().min(3, t('form.validation.professorMinLength')),
98
- vagas: z.coerce.number().min(1, t('form.validation.vagasMin')),
99
- dataInicio: z.string().min(1, t('form.validation.dataInicioRequired')),
100
- dataFim: z.string().min(1, t('form.validation.dataFimRequired')),
101
- horario: z.string().min(1, t('form.validation.horarioRequired')),
102
- status: z.string().min(1, t('form.validation.statusRequired')),
103
- });
403
+ return z
404
+ .object({
405
+ codigo: z.string().min(3, t('form.validation.codigoMinLength')),
406
+ curso: z.string().optional(),
407
+ courseId: z.coerce
408
+ .number({
409
+ invalid_type_error: t('form.validation.cursoRequired'),
410
+ })
411
+ .int()
412
+ .positive(t('form.validation.cursoRequired')),
413
+ tipo: z.string().min(1, t('form.validation.tipoRequired')),
414
+ professor: z.string().min(3, t('form.validation.professorMinLength')),
415
+ vagas: z.coerce.number().min(1, t('form.validation.vagasMin')),
416
+ dataInicio: z.string().min(1, t('form.validation.dataInicioRequired')),
417
+ dataFim: z.string().min(1, t('form.validation.dataFimRequired')),
418
+ horarioInicio: z
419
+ .string()
420
+ .min(1, t('form.validation.horarioInicioRequired'))
421
+ .regex(
422
+ /^([01]\d|2[0-3]):([0-5]\d)$/,
423
+ t('form.validation.horarioFormato')
424
+ ),
425
+ horarioFim: z
426
+ .string()
427
+ .min(1, t('form.validation.horarioFimRequired'))
428
+ .regex(
429
+ /^([01]\d|2[0-3]):([0-5]\d)$/,
430
+ t('form.validation.horarioFormato')
431
+ ),
432
+ sessionRecurrenceMode: z
433
+ .enum([
434
+ 'none',
435
+ 'daily',
436
+ 'weekly',
437
+ 'monthly',
438
+ 'yearly',
439
+ 'weekdays',
440
+ 'custom',
441
+ ] as const)
442
+ .default('none'),
443
+ sessionRecurrenceCustomFrequency: z
444
+ .enum(['daily', 'weekly', 'monthly', 'yearly'] as const)
445
+ .default('weekly'),
446
+ sessionRecurrenceInterval: z.coerce
447
+ .number()
448
+ .min(1, t('form.validation.sessionRecurrenceIntervalMin'))
449
+ .default(1),
450
+ sessionRecurrenceDaysOfWeek: z
451
+ .array(z.enum(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const))
452
+ .default([]),
453
+ sessionRecurrenceUntil: z.string().optional(),
454
+ sessionTitleMode: z
455
+ .enum(['default-course-code', 'custom'] as const)
456
+ .default('default-course-code'),
457
+ sessionTitle: z.string().optional(),
458
+ status: z.string().min(1, t('form.validation.statusRequired')),
459
+ instructorId: z.number().int().positive().optional(),
460
+ })
461
+ .superRefine((values, ctx) => {
462
+ if (
463
+ values.dataInicio &&
464
+ values.dataFim &&
465
+ values.dataFim < values.dataInicio
466
+ ) {
467
+ ctx.addIssue({
468
+ code: z.ZodIssueCode.custom,
469
+ path: ['dataFim'],
470
+ message: 'A data final nao pode ser anterior a data inicial.',
471
+ });
472
+ }
473
+
474
+ if (
475
+ values.horarioInicio &&
476
+ values.horarioFim &&
477
+ values.horarioFim < values.horarioInicio
478
+ ) {
479
+ ctx.addIssue({
480
+ code: z.ZodIssueCode.custom,
481
+ path: ['horarioFim'],
482
+ message: 'O horario final nao pode ser anterior ao horario inicial.',
483
+ });
484
+ }
485
+
486
+ if (values.sessionRecurrenceMode !== 'none') {
487
+ if (!values.sessionRecurrenceUntil) {
488
+ ctx.addIssue({
489
+ code: z.ZodIssueCode.custom,
490
+ path: ['sessionRecurrenceUntil'],
491
+ message: t('form.validation.sessionRecurrenceUntilRequired'),
492
+ });
493
+ } else if (values.sessionRecurrenceUntil < values.dataInicio) {
494
+ ctx.addIssue({
495
+ code: z.ZodIssueCode.custom,
496
+ path: ['sessionRecurrenceUntil'],
497
+ message: t('form.validation.sessionRecurrenceUntilAfterStart'),
498
+ });
499
+ }
500
+ }
501
+
502
+ const requiresDays =
503
+ values.sessionRecurrenceMode === 'weekly' ||
504
+ values.sessionRecurrenceMode === 'weekdays' ||
505
+ (values.sessionRecurrenceMode === 'custom' &&
506
+ values.sessionRecurrenceCustomFrequency === 'weekly');
507
+
508
+ if (requiresDays && values.sessionRecurrenceDaysOfWeek.length === 0) {
509
+ ctx.addIssue({
510
+ code: z.ZodIssueCode.custom,
511
+ path: ['sessionRecurrenceDaysOfWeek'],
512
+ message: t('form.validation.sessionRecurrenceDaysRequired'),
513
+ });
514
+ }
515
+ });
104
516
  }
105
517
 
106
518
  type TurmaForm = {
107
519
  codigo: string;
108
520
  curso: string;
521
+ courseId?: number;
522
+ instructorId?: number;
109
523
  tipo: string;
110
524
  professor: string;
111
525
  vagas: number;
112
526
  dataInicio: string;
113
527
  dataFim: string;
114
- horario: string;
528
+ horarioInicio: string;
529
+ horarioFim: string;
530
+ sessionRecurrenceMode: SessionRecurrenceMode;
531
+ sessionRecurrenceCustomFrequency: SessionRecurrenceFrequency;
532
+ sessionRecurrenceInterval: number;
533
+ sessionRecurrenceDaysOfWeek: SessionRecurrenceDay[];
534
+ sessionRecurrenceUntil?: string;
535
+ sessionTitleMode: 'default-course-code' | 'custom';
536
+ sessionTitle?: string;
115
537
  status: string;
116
538
  };
117
539
 
118
- // ── Constants ─────────────────────────────────────────────────────────────────
540
+ type ViewMode = 'cards' | 'list';
119
541
 
120
- const CURSOS = [
121
- 'React Avancado',
122
- 'UX Design Fundamentals',
123
- 'Python para Data Science',
124
- 'Gestao de Projetos Ageis',
125
- 'Node.js Completo',
126
- 'Marketing Digital',
127
- 'TypeScript na Pratica',
128
- 'Design System',
129
- 'Excel para Negocios',
130
- 'Lideranca e Comunicacao',
131
- ];
542
+ // ── Constants ─────────────────────────────────────────────────────────────────
132
543
 
133
544
  const STATUS_VARIANT: Record<
134
545
  string,
@@ -140,191 +551,110 @@ const STATUS_VARIANT: Record<
140
551
  cancelada: 'destructive',
141
552
  };
142
553
 
143
- const TIPO_ICON: Record<string, React.FC<{ className?: string }>> = {
554
+ const TIPO_ICON: Record<string, LucideIcon> = {
144
555
  presencial: MapPin,
145
556
  online: Monitor,
146
557
  hibrida: Laptop,
147
558
  };
148
559
 
149
560
  const PAGE_SIZES = [6, 12, 24];
561
+ const TIME_OPTIONS = Array.from({ length: 32 }, (_, index) => {
562
+ const hour = 6 + Math.floor(index / 2);
563
+ const minute = index % 2 === 0 ? '00' : '30';
564
+
565
+ return `${String(hour).padStart(2, '0')}:${minute}`;
566
+ });
567
+
568
+ function formatDateRangeLabel(
569
+ start: string | undefined,
570
+ end: string | undefined,
571
+ getSettingValue: (k: string) => any,
572
+ locale: string
573
+ ) {
574
+ if (start && end)
575
+ return `${formatDateLocalized(start, getSettingValue, locale)} – ${formatDateLocalized(end, getSettingValue, locale)}`;
576
+ if (start) return formatDateLocalized(start, getSettingValue, locale);
577
+ return '';
578
+ }
579
+
580
+ function buildSessionRecurrencePayload(values: TurmaForm) {
581
+ if (values.sessionRecurrenceMode === 'none') {
582
+ return undefined;
583
+ }
584
+
585
+ const frequency =
586
+ values.sessionRecurrenceMode === 'custom'
587
+ ? values.sessionRecurrenceCustomFrequency
588
+ : values.sessionRecurrenceMode === 'weekdays'
589
+ ? 'weekly'
590
+ : values.sessionRecurrenceMode;
591
+
592
+ const daysOfWeek =
593
+ values.sessionRecurrenceMode === 'weekdays'
594
+ ? (['MO', 'TU', 'WE', 'TH', 'FR'] as SessionRecurrenceDay[])
595
+ : frequency === 'weekly'
596
+ ? values.sessionRecurrenceDaysOfWeek
597
+ : undefined;
598
+
599
+ return {
600
+ frequency,
601
+ interval: values.sessionRecurrenceInterval,
602
+ until: values.sessionRecurrenceUntil!,
603
+ ...(daysOfWeek?.length ? { daysOfWeek } : {}),
604
+ };
605
+ }
606
+
607
+ function inferRecurrenceMode(
608
+ summary?: SessionRecurrenceSummary | null
609
+ ): SessionRecurrenceMode {
610
+ if (!summary?.isRecurring || !summary.frequency) {
611
+ return 'none';
612
+ }
613
+
614
+ if (
615
+ summary.frequency === 'weekly' &&
616
+ Array.isArray(summary.daysOfWeek) &&
617
+ summary.daysOfWeek.join(',') === 'MO,TU,WE,TH,FR' &&
618
+ (summary.interval ?? 1) === 1
619
+ ) {
620
+ return 'weekdays';
621
+ }
622
+
623
+ if ((summary.interval ?? 1) !== 1) {
624
+ return 'custom';
625
+ }
626
+
627
+ return summary.frequency;
628
+ }
629
+
630
+ function getSuggestedEndTime(startTime?: string) {
631
+ if (!startTime) return '';
150
632
 
151
- function formatDate(d: string) {
152
- const [y, m, day] = d.split('-');
153
- return `${day}/${m}/${y}`;
633
+ const startIndex = TIME_OPTIONS.indexOf(startTime);
634
+ if (startIndex === -1) return '';
635
+
636
+ return TIME_OPTIONS[Math.min(startIndex + 1, TIME_OPTIONS.length - 1)] ?? '';
637
+ }
638
+
639
+ function createClassCodeSeed() {
640
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
641
+ .replace(/[^a-z0-9]/gi, '')
642
+ .toUpperCase();
154
643
  }
155
644
 
156
- // ── Seed Data ─────────────────────────────────────────────────────────────────
157
-
158
- const initialTurmas: Turma[] = [
159
- {
160
- id: 1,
161
- codigo: 'T-2024-001',
162
- curso: 'React Avancado',
163
- cursoId: 1,
164
- tipo: 'online',
165
- dataInicio: '2024-02-01',
166
- dataFim: '2024-04-30',
167
- horario: '19:00 - 22:00',
168
- status: 'em_andamento',
169
- vagas: 30,
170
- matriculados: 28,
171
- professor: 'Carlos Silva',
172
- },
173
- {
174
- id: 2,
175
- codigo: 'T-2024-002',
176
- curso: 'UX Design Fundamentals',
177
- cursoId: 2,
178
- tipo: 'presencial',
179
- dataInicio: '2024-03-01',
180
- dataFim: '2024-05-15',
181
- horario: '14:00 - 17:00',
182
- status: 'em_andamento',
183
- vagas: 25,
184
- matriculados: 25,
185
- professor: 'Ana Oliveira',
186
- },
187
- {
188
- id: 3,
189
- codigo: 'T-2024-003',
190
- curso: 'Python para Data Science',
191
- cursoId: 3,
192
- tipo: 'online',
193
- dataInicio: '2024-04-01',
194
- dataFim: '2024-07-30',
195
- horario: '19:00 - 21:00',
196
- status: 'aberta',
197
- vagas: 35,
198
- matriculados: 20,
199
- professor: 'Roberto Santos',
200
- },
201
- {
202
- id: 4,
203
- codigo: 'T-2024-004',
204
- curso: 'Gestao de Projetos Ageis',
205
- cursoId: 4,
206
- tipo: 'hibrida',
207
- dataInicio: '2024-01-15',
208
- dataFim: '2024-03-15',
209
- horario: '08:00 - 11:00',
210
- status: 'concluida',
211
- vagas: 20,
212
- matriculados: 18,
213
- professor: 'Maria Costa',
214
- },
215
- {
216
- id: 5,
217
- codigo: 'T-2024-005',
218
- curso: 'Node.js Completo',
219
- cursoId: 5,
220
- tipo: 'online',
221
- dataInicio: '2024-05-01',
222
- dataFim: '2024-08-30',
223
- horario: '19:00 - 22:00',
224
- status: 'aberta',
225
- vagas: 30,
226
- matriculados: 12,
227
- professor: 'Pedro Almeida',
228
- },
229
- {
230
- id: 6,
231
- codigo: 'T-2024-006',
232
- curso: 'Marketing Digital',
233
- cursoId: 6,
234
- tipo: 'presencial',
235
- dataInicio: '2024-06-01',
236
- dataFim: '2024-08-30',
237
- horario: '10:00 - 12:00',
238
- status: 'cancelada',
239
- vagas: 40,
240
- matriculados: 0,
241
- professor: 'Julia Ferreira',
242
- },
243
- {
244
- id: 7,
245
- codigo: 'T-2024-007',
246
- curso: 'TypeScript na Pratica',
247
- cursoId: 7,
248
- tipo: 'online',
249
- dataInicio: '2024-03-15',
250
- dataFim: '2024-06-15',
251
- horario: '19:00 - 21:30',
252
- status: 'em_andamento',
253
- vagas: 25,
254
- matriculados: 22,
255
- professor: 'Carlos Silva',
256
- },
257
- {
258
- id: 8,
259
- codigo: 'T-2024-008',
260
- curso: 'Design System',
261
- cursoId: 8,
262
- tipo: 'hibrida',
263
- dataInicio: '2024-04-15',
264
- dataFim: '2024-06-30',
265
- horario: '14:00 - 16:00',
266
- status: 'aberta',
267
- vagas: 20,
268
- matriculados: 15,
269
- professor: 'Ana Oliveira',
270
- },
271
- {
272
- id: 9,
273
- codigo: 'T-2024-009',
274
- curso: 'Excel para Negocios',
275
- cursoId: 9,
276
- tipo: 'presencial',
277
- dataInicio: '2024-01-10',
278
- dataFim: '2024-02-28',
279
- horario: '08:00 - 10:00',
280
- status: 'concluida',
281
- vagas: 50,
282
- matriculados: 48,
283
- professor: 'Maria Costa',
284
- },
285
- {
286
- id: 10,
287
- codigo: 'T-2024-010',
288
- curso: 'Lideranca e Comunicacao',
289
- cursoId: 10,
290
- tipo: 'presencial',
291
- dataInicio: '2024-05-15',
292
- dataFim: '2024-07-15',
293
- horario: '18:00 - 20:00',
294
- status: 'aberta',
295
- vagas: 30,
296
- matriculados: 8,
297
- professor: 'Roberto Santos',
298
- },
299
- {
300
- id: 11,
301
- codigo: 'T-2024-011',
302
- curso: 'React Avancado',
303
- cursoId: 1,
304
- tipo: 'hibrida',
305
- dataInicio: '2024-06-01',
306
- dataFim: '2024-09-30',
307
- horario: '19:00 - 21:00',
308
- status: 'aberta',
309
- vagas: 25,
310
- matriculados: 5,
311
- professor: 'Carlos Silva',
312
- },
313
- {
314
- id: 12,
315
- codigo: 'T-2024-012',
316
- curso: 'Python para Data Science',
317
- cursoId: 3,
318
- tipo: 'presencial',
319
- dataInicio: '2024-01-05',
320
- dataFim: '2024-03-28',
321
- horario: '14:00 - 17:00',
322
- status: 'concluida',
323
- vagas: 30,
324
- matriculados: 29,
325
- professor: 'Roberto Santos',
326
- },
327
- ];
645
+ function buildClassCode(courseTitle?: string, seed?: string) {
646
+ const normalizedPrefix = (courseTitle ?? '')
647
+ .normalize('NFD')
648
+ .replace(/[\u0300-\u036f]/g, '')
649
+ .replace(/[^a-z0-9]/gi, '')
650
+ .toUpperCase()
651
+ .slice(0, 4);
652
+
653
+ const prefix = normalizedPrefix || 'TURM';
654
+ const codeSeed = seed || createClassCodeSeed();
655
+
656
+ return `${prefix}-${codeSeed}`;
657
+ }
328
658
 
329
659
  // ── Animations ────────────────────────────────────────────────────────────────
330
660
 
@@ -342,29 +672,40 @@ const stagger = {
342
672
 
343
673
  export default function TurmasPage() {
344
674
  const t = useTranslations('lms.ClassesPage');
345
- const pathname = usePathname();
675
+ const courseSheetT = useTranslations('lms.CoursesPage');
346
676
  const router = useRouter();
677
+ const { request, currentLocaleCode, getSettingValue, locales } = useApp();
347
678
 
348
- const [loading, setLoading] = useState(true);
349
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
350
- const [turmas, setTurmas] = useState<Turma[]>(initialTurmas);
351
679
  const [sheetOpen, setSheetOpen] = useState(false);
352
680
  const [editingTurma, setEditingTurma] = useState<Turma | null>(null);
353
681
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
354
682
  const [turmaToDelete, setTurmaToDelete] = useState<Turma | null>(null);
355
683
  const [saving, setSaving] = useState(false);
684
+ const [savingCourse, setSavingCourse] = useState(false);
685
+ const [dateRangeOpen, setDateRangeOpen] = useState(false);
686
+ const [dateRangeDraft, setDateRangeDraft] = useState<DateRange | undefined>();
687
+ const [createCodeSeed, setCreateCodeSeed] = useState('');
688
+ const [courseSheetOpen, setCourseSheetOpen] = useState(false);
689
+ const [customRecurrenceDialogOpen, setCustomRecurrenceDialogOpen] =
690
+ useState(false);
691
+ const [previousRecurrenceMode, setPreviousRecurrenceMode] =
692
+ useState<SessionRecurrenceMode>('none');
693
+ const [professorOpen, setProfessorOpen] = useState(false);
694
+ const [professorSearch, setProfessorSearch] = useState('');
695
+ const [createProfessorDialogOpen, setCreateProfessorDialogOpen] =
696
+ useState(false);
356
697
 
357
698
  // Search inputs (uncommitted)
358
699
  const [buscaInput, setBuscaInput] = useState('');
700
+ const [buscaDebounced, setBuscaDebounced] = useState('');
359
701
  const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
360
702
  const [filtroTipoInput, setFiltroTipoInput] = useState('todos');
361
703
  const [filtroCursoInput, setFiltroCursoInput] = useState('todos');
362
-
363
- // Applied filters
364
- const [buscaApplied, setBuscaApplied] = useState('');
365
- const [filtroStatusApplied, setFiltroStatusApplied] = useState('todos');
366
- const [filtroTipoApplied, setFiltroTipoApplied] = useState('todos');
367
- const [filtroCursoApplied, setFiltroCursoApplied] = useState('todos');
704
+ const [viewMode, setViewMode] = usePersistedViewMode<ViewMode>({
705
+ storageKey: 'lms:classes:view-mode',
706
+ defaultValue: 'cards',
707
+ allowedValues: ['cards', 'list'],
708
+ });
368
709
 
369
710
  // Pagination
370
711
  const [currentPage, setCurrentPage] = useState(1);
@@ -374,74 +715,588 @@ export default function TurmasPage() {
374
715
  const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
375
716
  new Map()
376
717
  );
718
+ const customRecurrenceConfirmedRef = useRef(false);
377
719
 
378
720
  const form = useForm<TurmaForm>({
379
721
  resolver: zodResolver(getTurmaSchema(t)),
380
722
  defaultValues: {
381
723
  codigo: '',
382
724
  curso: '',
725
+ courseId: undefined,
726
+ instructorId: undefined,
383
727
  tipo: 'online',
384
728
  professor: '',
385
729
  vagas: 30,
386
730
  dataInicio: '',
387
731
  dataFim: '',
388
- horario: '',
732
+ horarioInicio: '',
733
+ horarioFim: '',
734
+ sessionRecurrenceMode: 'none',
735
+ sessionRecurrenceCustomFrequency: 'weekly',
736
+ sessionRecurrenceInterval: 1,
737
+ sessionRecurrenceDaysOfWeek: [],
738
+ sessionRecurrenceUntil: '',
739
+ sessionTitleMode: 'default-course-code',
740
+ sessionTitle: '',
389
741
  status: 'aberta',
390
742
  },
391
743
  });
744
+ const courseForm = useForm<CourseSheetFormValues>({
745
+ resolver: zodResolver(getCourseSheetSchema(courseSheetT)),
746
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
747
+ });
748
+
749
+ const watchedFormValues = useWatch({ control: form.control });
750
+
751
+ const { data: coursesResponse, refetch: refetchCourseOptions } =
752
+ useQuery<ApiCourseList>({
753
+ queryKey: ['lms-courses-for-class-form'],
754
+ queryFn: async () => {
755
+ const response = await request<ApiCourseList>({
756
+ url: '/lms/courses',
757
+ method: 'GET',
758
+ params: {
759
+ page: 1,
760
+ pageSize: 500,
761
+ },
762
+ });
763
+ return response.data;
764
+ },
765
+ });
766
+
767
+ const { data: categoryListData, refetch: refetchCategoryOptions } =
768
+ useQuery<ApiCategoryList>({
769
+ queryKey: ['category-options'],
770
+ queryFn: async () => {
771
+ const response = await request<ApiCategoryList>({
772
+ url: '/category',
773
+ method: 'GET',
774
+ params: {
775
+ page: 1,
776
+ pageSize: 500,
777
+ status: 'all',
778
+ },
779
+ });
780
+
781
+ const payload = response.data as ApiCategoryList | ApiCategory[];
782
+ if (Array.isArray(payload)) {
783
+ return {
784
+ data: payload,
785
+ total: payload.length,
786
+ page: 1,
787
+ pageSize: payload.length,
788
+ };
789
+ }
790
+
791
+ return payload;
792
+ },
793
+ initialData: {
794
+ data: [],
795
+ total: 0,
796
+ page: 1,
797
+ pageSize: 500,
798
+ },
799
+ });
800
+
801
+ const {
802
+ data: professorOptions = [],
803
+ isFetching: loadingProfessores,
804
+ refetch: refetchProfessorOptions,
805
+ } = useQuery<InstructorOption[]>({
806
+ queryKey: ['lms-classes-professors', professorSearch],
807
+ queryFn: async () => {
808
+ const response = await request<
809
+ | InstructorApiRow[]
810
+ | {
811
+ data?: InstructorApiRow[];
812
+ items?: InstructorApiRow[];
813
+ rows?: InstructorApiRow[];
814
+ }
815
+ >({
816
+ url: '/lms/instructors',
817
+ method: 'GET',
818
+ params: {
819
+ page: 1,
820
+ pageSize: 100,
821
+ qualificationSlugs: ['class-sessions'],
822
+ ...(professorSearch.trim() ? { search: professorSearch.trim() } : {}),
823
+ },
824
+ });
825
+
826
+ const payload = response.data;
827
+ const rows = Array.isArray(payload)
828
+ ? payload
829
+ : Array.isArray(payload?.data)
830
+ ? payload.data
831
+ : Array.isArray(payload?.items)
832
+ ? payload.items
833
+ : Array.isArray(payload?.rows)
834
+ ? payload.rows
835
+ : [];
836
+
837
+ const unique = new Map<number, InstructorOption>();
838
+
839
+ for (const row of rows) {
840
+ const normalized = normalizeInstructorOption(row);
841
+ if (!normalized) continue;
842
+ unique.set(normalized.id, normalized);
843
+ }
844
+
845
+ return Array.from(unique.values()).sort((a, b) =>
846
+ a.name.localeCompare(b.name)
847
+ );
848
+ },
849
+ initialData: [],
850
+ });
392
851
 
393
852
  useEffect(() => {
394
- const t = setTimeout(() => setLoading(false), 700);
395
- return () => clearTimeout(t);
396
- }, []);
853
+ if (professorOpen) {
854
+ void refetchProfessorOptions();
855
+ }
856
+ }, [professorOpen, refetchProfessorOptions]);
857
+
858
+ useEffect(() => {
859
+ if (courseSheetOpen) {
860
+ void refetchCategoryOptions();
861
+ }
862
+ }, [courseSheetOpen, refetchCategoryOptions]);
863
+
864
+ const {
865
+ data: classesResponse,
866
+ refetch: refetchClasses,
867
+ isLoading: isClassesLoading,
868
+ isFetching: isClassesFetching,
869
+ } = useQuery<ApiClassList>({
870
+ queryKey: [
871
+ 'lms-classes-list',
872
+ currentPage,
873
+ pageSize,
874
+ buscaDebounced,
875
+ filtroStatusInput,
876
+ filtroTipoInput,
877
+ filtroCursoInput,
878
+ coursesResponse?.data?.length ?? 0,
879
+ ],
880
+ queryFn: async () => {
881
+ const selectedCourseId =
882
+ filtroCursoInput !== 'todos'
883
+ ? getCourseIdByTitle(coursesResponse?.data ?? [], filtroCursoInput)
884
+ : undefined;
885
+
886
+ const response = await request<ApiClassList>({
887
+ url: '/lms/classes',
888
+ method: 'GET',
889
+ params: {
890
+ page: currentPage,
891
+ pageSize,
892
+ ...(buscaDebounced ? { search: buscaDebounced } : {}),
893
+ ...(filtroStatusInput !== 'todos'
894
+ ? { status: toApiStatus(filtroStatusInput) }
895
+ : {}),
896
+ ...(filtroTipoInput !== 'todos'
897
+ ? { deliveryMode: toApiType(filtroTipoInput) }
898
+ : {}),
899
+ ...(selectedCourseId ? { courseId: selectedCourseId } : {}),
900
+ },
901
+ });
902
+
903
+ return response.data;
904
+ },
905
+ placeholderData: (old) => old,
906
+ staleTime: 0,
907
+ refetchOnMount: 'always',
908
+ refetchOnWindowFocus: true,
909
+ });
910
+
911
+ const { data: classStats, refetch: refetchStats } = useQuery<ApiClassStats>({
912
+ queryKey: ['lms-classes-stats'],
913
+ queryFn: async () => {
914
+ const response = await request<ApiClassStats>({
915
+ url: '/lms/classes/stats',
916
+ method: 'GET',
917
+ });
918
+ return response.data;
919
+ },
920
+ staleTime: 0,
921
+ refetchOnMount: 'always',
922
+ refetchOnWindowFocus: true,
923
+ });
924
+
925
+ useEffect(() => {
926
+ const timeoutId = setTimeout(() => {
927
+ setBuscaDebounced(buscaInput.trim());
928
+ }, 350);
929
+
930
+ return () => clearTimeout(timeoutId);
931
+ }, [buscaInput]);
932
+
933
+ useEffect(() => {
934
+ setCurrentPage(1);
935
+ }, [buscaDebounced, filtroStatusInput, filtroTipoInput, filtroCursoInput]);
936
+
937
+ useEffect(() => {
938
+ if (!sheetOpen || editingTurma || !createCodeSeed) return;
939
+
940
+ const nextCode = buildClassCode(
941
+ watchedFormValues.curso || undefined,
942
+ createCodeSeed
943
+ );
944
+
945
+ if (form.getValues('codigo') === nextCode) return;
946
+
947
+ form.setValue('codigo', nextCode, {
948
+ shouldDirty: false,
949
+ shouldTouch: false,
950
+ shouldValidate: true,
951
+ });
952
+ }, [createCodeSeed, editingTurma, form, sheetOpen, watchedFormValues.curso]);
953
+
954
+ const courseOptions = useMemo(
955
+ () =>
956
+ (coursesResponse?.data ?? []).map((item) => ({
957
+ id: item.id,
958
+ title: item.title,
959
+ })),
960
+ [coursesResponse]
961
+ );
397
962
 
398
963
  const uniqueCursos = useMemo(
399
- () => [...new Set(turmas.map((t) => t.curso))].sort(),
400
- [turmas]
964
+ () => [...new Set(courseOptions.map((course) => course.title))].sort(),
965
+ [courseOptions]
966
+ );
967
+
968
+ const selectedCourse = useMemo(
969
+ () =>
970
+ courseOptions.find((item) => item.id === watchedFormValues.courseId) ??
971
+ null,
972
+ [courseOptions, watchedFormValues.courseId]
973
+ );
974
+
975
+ const selectedCourseTitle = selectedCourse?.title ?? watchedFormValues.curso;
976
+ const defaultSessionTitle = useMemo(
977
+ () => getDefaultSessionTitle(selectedCourseTitle, watchedFormValues.codigo),
978
+ [selectedCourseTitle, watchedFormValues.codigo]
401
979
  );
980
+ const filteredEndTimeOptions = useMemo(() => {
981
+ const startTime = watchedFormValues.horarioInicio ?? '';
402
982
 
403
- // ── Filtering ────────────────────────────────────────────────────────────
983
+ if (!startTime) {
984
+ return TIME_OPTIONS;
985
+ }
986
+
987
+ return TIME_OPTIONS.filter((time) => time >= startTime);
988
+ }, [watchedFormValues.horarioInicio]);
404
989
 
405
- const filteredTurmas = useMemo(
990
+ const categoryOptions = useMemo<CourseCategoryOption[]>(
406
991
  () =>
407
- turmas.filter((t) => {
408
- const q = buscaApplied.toLowerCase();
409
- const matchBusca =
410
- !q ||
411
- t.codigo.toLowerCase().includes(q) ||
412
- t.curso.toLowerCase().includes(q) ||
413
- t.professor.toLowerCase().includes(q);
414
- const matchStatus =
415
- filtroStatusApplied === 'todos' || t.status === filtroStatusApplied;
416
- const matchTipo =
417
- filtroTipoApplied === 'todos' || t.tipo === filtroTipoApplied;
418
- const matchCurso =
419
- filtroCursoApplied === 'todos' || t.curso === filtroCursoApplied;
420
- return matchBusca && matchStatus && matchTipo && matchCurso;
421
- }),
422
- [
423
- turmas,
424
- buscaApplied,
425
- filtroStatusApplied,
426
- filtroTipoApplied,
427
- filtroCursoApplied,
428
- ]
992
+ (categoryListData?.data ?? [])
993
+ .filter((category) => !!category.slug)
994
+ .map((category) => ({
995
+ value: category.slug,
996
+ label: category.name || category.slug,
997
+ }))
998
+ .sort((a, b) => a.label.localeCompare(b.label)),
999
+ [categoryListData]
429
1000
  );
430
1001
 
431
- const totalPages = Math.max(1, Math.ceil(filteredTurmas.length / pageSize));
432
- const safePage = Math.min(currentPage, totalPages);
433
- const paginatedTurmas = filteredTurmas.slice(
434
- (safePage - 1) * pageSize,
435
- safePage * pageSize
1002
+ const recurrenceDayOptions = useMemo(
1003
+ () =>
1004
+ (
1005
+ [
1006
+ ['MO', t('form.recurrence.customDialog.days.MO')],
1007
+ ['TU', t('form.recurrence.customDialog.days.TU')],
1008
+ ['WE', t('form.recurrence.customDialog.days.WE')],
1009
+ ['TH', t('form.recurrence.customDialog.days.TH')],
1010
+ ['FR', t('form.recurrence.customDialog.days.FR')],
1011
+ ['SA', t('form.recurrence.customDialog.days.SA')],
1012
+ ['SU', t('form.recurrence.customDialog.days.SU')],
1013
+ ] as const
1014
+ ).map(([value, label]) => ({
1015
+ value: value as SessionRecurrenceDay,
1016
+ label,
1017
+ })),
1018
+ [t]
436
1019
  );
437
1020
 
438
- function handleSearch(e: React.FormEvent) {
439
- e.preventDefault();
440
- setBuscaApplied(buscaInput);
441
- setFiltroStatusApplied(filtroStatusInput);
442
- setFiltroTipoApplied(filtroTipoInput);
443
- setFiltroCursoApplied(filtroCursoInput);
444
- setCurrentPage(1);
1021
+ const recurrenceSummaryText = useMemo(() => {
1022
+ const until = watchedFormValues.sessionRecurrenceUntil
1023
+ ? formatDateLocalized(
1024
+ watchedFormValues.sessionRecurrenceUntil,
1025
+ getSettingValue,
1026
+ currentLocaleCode
1027
+ )
1028
+ : '--';
1029
+
1030
+ return t(
1031
+ `form.recurrence.summary.${watchedFormValues.sessionRecurrenceMode}`,
1032
+ {
1033
+ until,
1034
+ }
1035
+ );
1036
+ }, [
1037
+ t,
1038
+ watchedFormValues.sessionRecurrenceMode,
1039
+ watchedFormValues.sessionRecurrenceUntil,
1040
+ getSettingValue,
1041
+ currentLocaleCode,
1042
+ ]);
1043
+
1044
+ useEffect(() => {
1045
+ if (
1046
+ !selectedCourseTitle ||
1047
+ form.getValues('curso') === selectedCourseTitle
1048
+ ) {
1049
+ return;
1050
+ }
1051
+
1052
+ form.setValue('curso', selectedCourseTitle, {
1053
+ shouldDirty: false,
1054
+ shouldTouch: false,
1055
+ shouldValidate: false,
1056
+ });
1057
+ }, [form, selectedCourseTitle]);
1058
+
1059
+ useEffect(() => {
1060
+ if (!dateRangeOpen) return;
1061
+
1062
+ setDateRangeDraft({
1063
+ from: watchedFormValues.dataInicio
1064
+ ? new Date(`${watchedFormValues.dataInicio}T00:00:00`)
1065
+ : undefined,
1066
+ to: watchedFormValues.dataFim
1067
+ ? new Date(`${watchedFormValues.dataFim}T00:00:00`)
1068
+ : undefined,
1069
+ });
1070
+ }, [dateRangeOpen, watchedFormValues.dataFim, watchedFormValues.dataInicio]);
1071
+
1072
+ useEffect(() => {
1073
+ if (
1074
+ watchedFormValues.sessionTitleMode !== 'default-course-code' ||
1075
+ form.getValues('sessionTitle') === defaultSessionTitle
1076
+ ) {
1077
+ return;
1078
+ }
1079
+
1080
+ form.setValue('sessionTitle', defaultSessionTitle, {
1081
+ shouldDirty: false,
1082
+ shouldTouch: false,
1083
+ shouldValidate: false,
1084
+ });
1085
+ }, [defaultSessionTitle, form, watchedFormValues.sessionTitleMode]);
1086
+
1087
+ const customRecurrenceFrequency =
1088
+ watchedFormValues.sessionRecurrenceMode === 'custom'
1089
+ ? watchedFormValues.sessionRecurrenceCustomFrequency
1090
+ : watchedFormValues.sessionRecurrenceMode === 'weekdays'
1091
+ ? 'weekly'
1092
+ : watchedFormValues.sessionRecurrenceMode === 'none'
1093
+ ? watchedFormValues.sessionRecurrenceCustomFrequency
1094
+ : watchedFormValues.sessionRecurrenceMode;
1095
+
1096
+ const customRecurrenceNeedsWeekdays =
1097
+ watchedFormValues.sessionRecurrenceMode === 'custom' &&
1098
+ customRecurrenceFrequency === 'weekly';
1099
+
1100
+ useEffect(() => {
1101
+ if (!watchedFormValues.dataFim) {
1102
+ return;
1103
+ }
1104
+
1105
+ if (!watchedFormValues.sessionRecurrenceUntil) {
1106
+ form.setValue('sessionRecurrenceUntil', watchedFormValues.dataFim, {
1107
+ shouldDirty: false,
1108
+ shouldTouch: false,
1109
+ shouldValidate: false,
1110
+ });
1111
+ }
1112
+ }, [
1113
+ form,
1114
+ watchedFormValues.dataFim,
1115
+ watchedFormValues.sessionRecurrenceUntil,
1116
+ ]);
1117
+
1118
+ useEffect(() => {
1119
+ if (!watchedFormValues.dataInicio) {
1120
+ return;
1121
+ }
1122
+
1123
+ const defaultDay = getDayCodeFromDate(watchedFormValues.dataInicio);
1124
+ const recurrenceMode = watchedFormValues.sessionRecurrenceMode;
1125
+ const recurrenceDays = watchedFormValues.sessionRecurrenceDaysOfWeek ?? [];
1126
+
1127
+ if (recurrenceMode === 'weekly' && recurrenceDays.length === 0) {
1128
+ form.setValue('sessionRecurrenceDaysOfWeek', [defaultDay], {
1129
+ shouldDirty: false,
1130
+ shouldTouch: false,
1131
+ shouldValidate: false,
1132
+ });
1133
+ }
1134
+
1135
+ if (recurrenceMode === 'weekdays') {
1136
+ form.setValue(
1137
+ 'sessionRecurrenceDaysOfWeek',
1138
+ ['MO', 'TU', 'WE', 'TH', 'FR'],
1139
+ {
1140
+ shouldDirty: false,
1141
+ shouldTouch: false,
1142
+ shouldValidate: false,
1143
+ }
1144
+ );
1145
+ }
1146
+ }, [
1147
+ form,
1148
+ watchedFormValues.dataInicio,
1149
+ watchedFormValues.sessionRecurrenceDaysOfWeek,
1150
+ watchedFormValues.sessionRecurrenceMode,
1151
+ ]);
1152
+
1153
+ useEffect(() => {
1154
+ const startTime = watchedFormValues.horarioInicio;
1155
+ const endTime = watchedFormValues.horarioFim;
1156
+
1157
+ if (!startTime) {
1158
+ return;
1159
+ }
1160
+
1161
+ if (!endTime) {
1162
+ const suggestedEndTime = getSuggestedEndTime(startTime);
1163
+ if (suggestedEndTime) {
1164
+ form.setValue('horarioFim', suggestedEndTime, {
1165
+ shouldDirty: true,
1166
+ shouldTouch: false,
1167
+ shouldValidate: true,
1168
+ });
1169
+ }
1170
+ return;
1171
+ }
1172
+
1173
+ if (endTime < startTime) {
1174
+ const suggestedEndTime = getSuggestedEndTime(startTime);
1175
+ form.setValue('horarioFim', suggestedEndTime || startTime, {
1176
+ shouldDirty: true,
1177
+ shouldTouch: true,
1178
+ shouldValidate: true,
1179
+ });
1180
+ }
1181
+ }, [form, watchedFormValues.horarioFim, watchedFormValues.horarioInicio]);
1182
+
1183
+ const professorNameById = useMemo<Map<number, string>>(() => {
1184
+ const map = new Map<number, string>();
1185
+ for (const p of professorOptions) {
1186
+ map.set(p.id, p.name);
1187
+ }
1188
+ return map;
1189
+ }, [professorOptions]);
1190
+
1191
+ const turmas = useMemo<Turma[]>(
1192
+ () =>
1193
+ (classesResponse?.data ?? []).map((item) => {
1194
+ const mapped = mapApiClass(item);
1195
+ if (
1196
+ (mapped.professor === '-' || !mapped.professor) &&
1197
+ mapped.instructorId
1198
+ ) {
1199
+ const fallback = professorNameById.get(mapped.instructorId);
1200
+ if (fallback) {
1201
+ return { ...mapped, professor: fallback };
1202
+ }
1203
+ }
1204
+ return mapped;
1205
+ }),
1206
+ [classesResponse, professorNameById]
1207
+ );
1208
+ const previewTurma = useMemo<Turma | null>(() => {
1209
+ if (!sheetOpen || !editingTurma) return null;
1210
+
1211
+ return {
1212
+ ...editingTurma,
1213
+ codigo: watchedFormValues.codigo ?? editingTurma.codigo,
1214
+ curso: selectedCourseTitle || editingTurma.curso,
1215
+ cursoId: watchedFormValues.courseId ?? editingTurma.cursoId,
1216
+ primaryColor: editingTurma.primaryColor ?? null,
1217
+ tipo:
1218
+ (watchedFormValues.tipo as Turma['tipo'] | undefined) ??
1219
+ editingTurma.tipo,
1220
+ professor: watchedFormValues.professor ?? editingTurma.professor,
1221
+ vagas: watchedFormValues.vagas ?? editingTurma.vagas,
1222
+ dataInicio: watchedFormValues.dataInicio ?? editingTurma.dataInicio,
1223
+ dataFim: watchedFormValues.dataFim ?? editingTurma.dataFim,
1224
+ horarioInicio:
1225
+ watchedFormValues.horarioInicio ?? editingTurma.horarioInicio,
1226
+ horarioFim: watchedFormValues.horarioFim ?? editingTurma.horarioFim,
1227
+ status:
1228
+ (watchedFormValues.status as Turma['status'] | undefined) ??
1229
+ editingTurma.status,
1230
+ };
1231
+ }, [editingTurma, selectedCourseTitle, sheetOpen, watchedFormValues]);
1232
+ const visibleTurmas = useMemo<Turma[]>(() => {
1233
+ if (!previewTurma) return turmas;
1234
+
1235
+ return turmas.map((turma) =>
1236
+ turma.id === previewTurma.id ? previewTurma : turma
1237
+ );
1238
+ }, [previewTurma, turmas]);
1239
+
1240
+ const totalItems = classesResponse?.total ?? 0;
1241
+ const totalPages = Math.max(classesResponse?.lastPage ?? 1, 1);
1242
+ const loading = isClassesLoading && !classesResponse;
1243
+ const isRefreshing = isClassesFetching && !isClassesLoading;
1244
+ const refetchersRef = useRef({
1245
+ refetchClasses,
1246
+ refetchStats,
1247
+ });
1248
+
1249
+ useEffect(() => {
1250
+ refetchersRef.current = {
1251
+ refetchClasses,
1252
+ refetchStats,
1253
+ };
1254
+ }, [refetchClasses, refetchStats]);
1255
+
1256
+ useEffect(() => {
1257
+ if (currentPage > totalPages) {
1258
+ setCurrentPage(totalPages);
1259
+ }
1260
+ }, [currentPage, totalPages]);
1261
+
1262
+ useEffect(() => {
1263
+ const refreshClassesData = () => {
1264
+ void refetchersRef.current.refetchClasses();
1265
+ void refetchersRef.current.refetchStats();
1266
+ };
1267
+
1268
+ const hasPendingRefresh =
1269
+ typeof window !== 'undefined' &&
1270
+ window.sessionStorage.getItem('lms:classes-needs-refresh') === '1';
1271
+
1272
+ if (hasPendingRefresh) {
1273
+ refreshClassesData();
1274
+ window.sessionStorage.removeItem('lms:classes-needs-refresh');
1275
+ }
1276
+
1277
+ const handleClassesUpdated = () => {
1278
+ refreshClassesData();
1279
+ if (typeof window !== 'undefined') {
1280
+ window.sessionStorage.removeItem('lms:classes-needs-refresh');
1281
+ }
1282
+ };
1283
+
1284
+ if (typeof window !== 'undefined') {
1285
+ window.addEventListener('lms:classes-updated', handleClassesUpdated);
1286
+ }
1287
+
1288
+ return () => {
1289
+ if (typeof window !== 'undefined') {
1290
+ window.removeEventListener('lms:classes-updated', handleClassesUpdated);
1291
+ }
1292
+ };
1293
+ }, []);
1294
+
1295
+ function notifyLmsDashboardUpdated() {
1296
+ if (typeof window === 'undefined') return;
1297
+
1298
+ window.sessionStorage.setItem('lms:dashboard-needs-refresh', '1');
1299
+ window.dispatchEvent(new CustomEvent('lms:dashboard-updated'));
445
1300
  }
446
1301
 
447
1302
  function clearFilters() {
@@ -449,18 +1304,20 @@ export default function TurmasPage() {
449
1304
  setFiltroStatusInput('todos');
450
1305
  setFiltroTipoInput('todos');
451
1306
  setFiltroCursoInput('todos');
452
- setBuscaApplied('');
453
- setFiltroStatusApplied('todos');
454
- setFiltroTipoApplied('todos');
455
- setFiltroCursoApplied('todos');
456
1307
  setCurrentPage(1);
457
1308
  }
458
1309
 
459
1310
  const hasActiveFilters =
460
- buscaApplied ||
461
- filtroStatusApplied !== 'todos' ||
462
- filtroTipoApplied !== 'todos' ||
463
- filtroCursoApplied !== 'todos';
1311
+ buscaInput.trim().length > 0 ||
1312
+ filtroStatusInput !== 'todos' ||
1313
+ filtroTipoInput !== 'todos' ||
1314
+ filtroCursoInput !== 'todos';
1315
+
1316
+ function openDeleteDialog(turma: Turma, e: React.MouseEvent) {
1317
+ e.stopPropagation();
1318
+ setTurmaToDelete(turma);
1319
+ setDeleteDialogOpen(true);
1320
+ }
464
1321
 
465
1322
  // ── Double-click ──────────────────────────────────────────────────────────
466
1323
 
@@ -469,7 +1326,7 @@ export default function TurmasPage() {
469
1326
  if (existing) {
470
1327
  clearTimeout(existing);
471
1328
  clickTimers.current.delete(turma.id);
472
- router.push(`/turmas/${turma.id}`);
1329
+ router.push(`/lms/classes/${turma.id}`);
473
1330
  } else {
474
1331
  const t = setTimeout(() => clickTimers.current.delete(turma.id), 300);
475
1332
  clickTimers.current.set(turma.id, t);
@@ -479,122 +1336,446 @@ export default function TurmasPage() {
479
1336
  // ── CRUD ──────────────────────────────────────────────────────────────────
480
1337
 
481
1338
  function openCreateSheet() {
1339
+ const nextSeed = createClassCodeSeed();
1340
+ const defaultTitle = getDefaultSessionTitle(
1341
+ undefined,
1342
+ buildClassCode(undefined, nextSeed)
1343
+ );
1344
+
1345
+ setCreateCodeSeed(nextSeed);
482
1346
  setEditingTurma(null);
1347
+ setPreviousRecurrenceMode('none');
1348
+ setDateRangeOpen(false);
1349
+ setDateRangeDraft(undefined);
483
1350
  form.reset({
484
- codigo: '',
1351
+ codigo: buildClassCode(undefined, nextSeed),
485
1352
  curso: '',
1353
+ courseId: undefined,
1354
+ instructorId: undefined,
486
1355
  tipo: 'online',
487
1356
  professor: '',
488
1357
  vagas: 30,
489
1358
  dataInicio: '',
490
1359
  dataFim: '',
491
- horario: '',
1360
+ horarioInicio: '',
1361
+ horarioFim: '',
1362
+ sessionRecurrenceMode: 'none',
1363
+ sessionRecurrenceCustomFrequency: 'weekly',
1364
+ sessionRecurrenceInterval: 1,
1365
+ sessionRecurrenceDaysOfWeek: [],
1366
+ sessionRecurrenceUntil: '',
1367
+ sessionTitleMode: 'default-course-code',
1368
+ sessionTitle: defaultTitle,
492
1369
  status: 'aberta',
493
1370
  });
494
1371
  setSheetOpen(true);
495
1372
  }
496
1373
 
497
- function openEditSheet(turma: Turma, e?: React.MouseEvent) {
1374
+ async function openEditSheet(turma: Turma, e?: React.MouseEvent) {
498
1375
  e?.stopPropagation();
499
- setEditingTurma(turma);
1376
+ setCreateCodeSeed('');
1377
+ const response = await request<ApiClass>({
1378
+ url: `/lms/classes/${turma.id}`,
1379
+ method: 'GET',
1380
+ });
1381
+ const detailedTurma = mapApiClass(response.data);
1382
+ const recurrenceSummary = response.data.sessionRecurrenceSummary;
1383
+ const recurrenceMode = inferRecurrenceMode(recurrenceSummary);
1384
+ const defaultDay = getDayCodeFromDate(detailedTurma.dataInicio);
1385
+
1386
+ setEditingTurma(detailedTurma);
1387
+ setPreviousRecurrenceMode(recurrenceMode);
1388
+ setDateRangeOpen(false);
1389
+ setDateRangeDraft({
1390
+ from: detailedTurma.dataInicio
1391
+ ? new Date(`${detailedTurma.dataInicio}T00:00:00`)
1392
+ : undefined,
1393
+ to: detailedTurma.dataFim
1394
+ ? new Date(`${detailedTurma.dataFim}T00:00:00`)
1395
+ : undefined,
1396
+ });
500
1397
  form.reset({
501
- codigo: turma.codigo,
502
- curso: turma.curso,
503
- tipo: turma.tipo,
504
- professor: turma.professor,
505
- vagas: turma.vagas,
506
- dataInicio: turma.dataInicio,
507
- dataFim: turma.dataFim,
508
- horario: turma.horario,
509
- status: turma.status,
1398
+ codigo: detailedTurma.codigo,
1399
+ curso: detailedTurma.curso,
1400
+ courseId: detailedTurma.cursoId,
1401
+ instructorId: detailedTurma.instructorId ?? undefined,
1402
+ tipo: detailedTurma.tipo,
1403
+ professor: detailedTurma.professor,
1404
+ vagas: detailedTurma.vagas,
1405
+ dataInicio: detailedTurma.dataInicio,
1406
+ dataFim: detailedTurma.dataFim,
1407
+ horarioInicio: detailedTurma.horarioInicio,
1408
+ horarioFim: detailedTurma.horarioFim,
1409
+ sessionRecurrenceMode: recurrenceMode,
1410
+ sessionRecurrenceCustomFrequency:
1411
+ recurrenceSummary?.frequency ?? 'weekly',
1412
+ sessionRecurrenceInterval: recurrenceSummary?.interval ?? 1,
1413
+ sessionRecurrenceDaysOfWeek:
1414
+ recurrenceSummary?.daysOfWeek ??
1415
+ (recurrenceMode === 'weekly' ? [defaultDay] : []),
1416
+ sessionRecurrenceUntil: recurrenceSummary?.until ?? detailedTurma.dataFim,
1417
+ sessionTitleMode:
1418
+ response.data.sessionTitle &&
1419
+ response.data.sessionTitle !==
1420
+ getDefaultSessionTitle(detailedTurma.curso, detailedTurma.codigo)
1421
+ ? 'custom'
1422
+ : 'default-course-code',
1423
+ sessionTitle:
1424
+ response.data.sessionTitle ??
1425
+ getDefaultSessionTitle(detailedTurma.curso, detailedTurma.codigo),
1426
+ status: detailedTurma.status,
510
1427
  });
511
1428
  setSheetOpen(true);
512
1429
  }
513
1430
 
514
- async function onSubmit(data: TurmaForm) {
515
- setSaving(true);
516
- await new Promise((r) => setTimeout(r, 500));
517
- if (editingTurma) {
518
- setTurmas((prev) =>
519
- prev.map((t) =>
520
- t.id === editingTurma.id
521
- ? {
522
- ...t,
523
- ...data,
524
- tipo: data.tipo as Turma['tipo'],
525
- status: data.status as Turma['status'],
526
- }
527
- : t
528
- )
1431
+ function handleRecurrenceModeChange(value: SessionRecurrenceMode) {
1432
+ if (value === 'custom') {
1433
+ setPreviousRecurrenceMode(
1434
+ watchedFormValues.sessionRecurrenceMode ?? 'none'
529
1435
  );
530
- toast.success(t('toasts.turmaUpdated'));
531
- } else {
532
- const newTurma: Turma = {
533
- id: Date.now(),
534
- ...data,
535
- cursoId: CURSOS.indexOf(data.curso) + 1,
536
- tipo: data.tipo as Turma['tipo'],
537
- status: data.status as Turma['status'],
538
- matriculados: 0,
539
- };
540
- setTurmas((prev) => [newTurma, ...prev]);
541
- toast.success(t('toasts.turmaCreated'));
542
- setSaving(false);
543
- setSheetOpen(false);
544
- setTimeout(() => router.push(`/turmas/${newTurma.id}`), 400);
1436
+ form.setValue('sessionRecurrenceMode', 'custom', {
1437
+ shouldDirty: true,
1438
+ shouldTouch: true,
1439
+ shouldValidate: true,
1440
+ });
1441
+ setCustomRecurrenceDialogOpen(true);
545
1442
  return;
546
1443
  }
547
- setSaving(false);
548
- setSheetOpen(false);
549
- }
550
1444
 
551
- function confirmDelete() {
552
- if (!turmaToDelete) return;
553
- setTurmas((prev) => prev.filter((t) => t.id !== turmaToDelete.id));
554
- toast.success(t('toasts.turmaRemoved'));
555
- setTurmaToDelete(null);
556
- setDeleteDialogOpen(false);
1445
+ form.setValue('sessionRecurrenceMode', value, {
1446
+ shouldDirty: true,
1447
+ shouldTouch: true,
1448
+ shouldValidate: true,
1449
+ });
1450
+
1451
+ if (value === 'weekdays') {
1452
+ form.setValue('sessionRecurrenceCustomFrequency', 'weekly', {
1453
+ shouldDirty: true,
1454
+ shouldTouch: false,
1455
+ shouldValidate: false,
1456
+ });
1457
+ form.setValue('sessionRecurrenceInterval', 1, {
1458
+ shouldDirty: true,
1459
+ shouldTouch: false,
1460
+ shouldValidate: false,
1461
+ });
1462
+ form.setValue(
1463
+ 'sessionRecurrenceDaysOfWeek',
1464
+ ['MO', 'TU', 'WE', 'TH', 'FR'],
1465
+ {
1466
+ shouldDirty: true,
1467
+ shouldTouch: false,
1468
+ shouldValidate: true,
1469
+ }
1470
+ );
1471
+ }
1472
+
1473
+ if (value === 'weekly' && watchedFormValues.dataInicio) {
1474
+ form.setValue(
1475
+ 'sessionRecurrenceDaysOfWeek',
1476
+ [getDayCodeFromDate(watchedFormValues.dataInicio)],
1477
+ {
1478
+ shouldDirty: true,
1479
+ shouldTouch: false,
1480
+ shouldValidate: true,
1481
+ }
1482
+ );
1483
+ }
1484
+
1485
+ setPreviousRecurrenceMode(value);
1486
+ }
1487
+
1488
+ function toggleCustomRecurrenceDay(day: SessionRecurrenceDay) {
1489
+ const currentDays = watchedFormValues.sessionRecurrenceDaysOfWeek ?? [];
1490
+ const nextDays = currentDays.includes(day)
1491
+ ? currentDays.filter((item) => item !== day)
1492
+ : [...currentDays, day];
1493
+
1494
+ form.setValue('sessionRecurrenceDaysOfWeek', nextDays, {
1495
+ shouldDirty: true,
1496
+ shouldTouch: true,
1497
+ shouldValidate: true,
1498
+ });
1499
+ }
1500
+
1501
+ function handleCustomRecurrenceCancel() {
1502
+ form.setValue('sessionRecurrenceMode', previousRecurrenceMode, {
1503
+ shouldDirty: true,
1504
+ shouldTouch: false,
1505
+ shouldValidate: true,
1506
+ });
1507
+ setCustomRecurrenceDialogOpen(false);
1508
+ }
1509
+
1510
+ async function handleCustomRecurrenceConfirm() {
1511
+ const valid = await form.trigger([
1512
+ 'sessionRecurrenceInterval',
1513
+ 'sessionRecurrenceUntil',
1514
+ 'sessionRecurrenceDaysOfWeek',
1515
+ 'dataInicio',
1516
+ ]);
1517
+
1518
+ if (!valid) {
1519
+ return;
1520
+ }
1521
+
1522
+ setPreviousRecurrenceMode('custom');
1523
+ customRecurrenceConfirmedRef.current = true;
1524
+ form.setValue('sessionRecurrenceMode', 'custom', {
1525
+ shouldDirty: true,
1526
+ shouldTouch: true,
1527
+ shouldValidate: true,
1528
+ });
1529
+ setCustomRecurrenceDialogOpen(false);
1530
+ }
1531
+
1532
+ function openCourseCreateSheet() {
1533
+ courseForm.reset(DEFAULT_COURSE_FORM_VALUES);
1534
+ setCourseSheetOpen(true);
1535
+ }
1536
+
1537
+ async function onSubmitCourse(data: CourseSheetFormValues) {
1538
+ setSavingCourse(true);
1539
+
1540
+ try {
1541
+ const payload = {
1542
+ slug: data.nomeInterno.trim(),
1543
+ title: data.tituloComercial,
1544
+ description: data.descricao,
1545
+ level: toApiCourseLevel(data.nivel),
1546
+ status: toApiCourseStatus(data.status),
1547
+ categorySlugs: data.categorias,
1548
+ primaryColor: data.primaryColor,
1549
+ primaryContrastColor: getContrastColor(data.primaryColor),
1550
+ secondaryColor: data.secondaryColor,
1551
+ secondaryContrastColor: getContrastColor(data.secondaryColor),
1552
+ };
1553
+
1554
+ const response = await request<ApiCreatedCourse>({
1555
+ url: '/lms/courses',
1556
+ method: 'POST',
1557
+ data: payload,
1558
+ });
1559
+
1560
+ const createdCourse = response.data;
1561
+ await refetchCourseOptions();
1562
+ form.setValue('courseId', createdCourse.id, {
1563
+ shouldDirty: true,
1564
+ shouldTouch: true,
1565
+ shouldValidate: true,
1566
+ });
1567
+ form.setValue('curso', createdCourse.title, {
1568
+ shouldDirty: true,
1569
+ shouldTouch: true,
1570
+ shouldValidate: false,
1571
+ });
1572
+ setCourseSheetOpen(false);
1573
+ toast.success(courseSheetT('toasts.courseCreated'));
1574
+ } catch {
1575
+ toast.error('Nao foi possivel cadastrar o curso.');
1576
+ } finally {
1577
+ setSavingCourse(false);
1578
+ }
1579
+ }
1580
+
1581
+ async function onSubmit(data: TurmaForm) {
1582
+ setSaving(true);
1583
+ const selectedCourse = courseOptions.find(
1584
+ (item) => item.id === data.courseId
1585
+ );
1586
+ const instructorId = data.instructorId ?? form.getValues('instructorId');
1587
+ const sessionTitle =
1588
+ data.sessionTitleMode === 'custom'
1589
+ ? data.sessionTitle?.trim() || defaultSessionTitle
1590
+ : defaultSessionTitle;
1591
+ const sessionTemplate = {
1592
+ title: sessionTitle,
1593
+ recurrence: buildSessionRecurrencePayload(data),
1594
+ };
1595
+
1596
+ if (!selectedCourse) {
1597
+ toast.error(t('form.validation.cursoRequired'));
1598
+ setSaving(false);
1599
+ return;
1600
+ }
1601
+
1602
+ try {
1603
+ if (editingTurma) {
1604
+ await request({
1605
+ url: `/lms/classes/${editingTurma.id}`,
1606
+ method: 'PATCH',
1607
+ data: {
1608
+ code: data.codigo,
1609
+ title: `${selectedCourse.title} - ${data.codigo}`,
1610
+ courseId: selectedCourse.id,
1611
+ instructorId: instructorId,
1612
+ deliveryMode: toApiType(data.tipo),
1613
+ status: toApiStatus(data.status),
1614
+ startDate: data.dataInicio,
1615
+ endDate: data.dataFim || null,
1616
+ startTime: data.horarioInicio,
1617
+ endTime: data.horarioFim,
1618
+ capacity: data.vagas,
1619
+ sessionTemplate,
1620
+ },
1621
+ });
1622
+
1623
+ toast.success(t('toasts.turmaUpdated'));
1624
+ await refetchProfessorOptions();
1625
+ } else {
1626
+ const response = await request<ApiClass>({
1627
+ url: '/lms/classes',
1628
+ method: 'POST',
1629
+ data: {
1630
+ code: data.codigo,
1631
+ title: `${selectedCourse.title} - ${data.codigo}`,
1632
+ courseId: selectedCourse.id,
1633
+ instructorId: instructorId,
1634
+ deliveryMode: toApiType(data.tipo),
1635
+ status: toApiStatus(data.status),
1636
+ startDate: data.dataInicio,
1637
+ endDate: data.dataFim || null,
1638
+ startTime: data.horarioInicio,
1639
+ endTime: data.horarioFim,
1640
+ capacity: data.vagas,
1641
+ sessionTemplate,
1642
+ },
1643
+ });
1644
+
1645
+ toast.success(t('toasts.turmaCreated'));
1646
+ await refetchProfessorOptions();
1647
+ await refetchClasses();
1648
+ await refetchStats();
1649
+ notifyLmsDashboardUpdated();
1650
+ setSaving(false);
1651
+ setSheetOpen(false);
1652
+
1653
+ return;
1654
+ }
1655
+
1656
+ await refetchClasses();
1657
+ await refetchStats();
1658
+ notifyLmsDashboardUpdated();
1659
+ setSheetOpen(false);
1660
+ } catch {
1661
+ toast.error('Nao foi possivel salvar a turma.');
1662
+ } finally {
1663
+ setSaving(false);
1664
+ }
1665
+ }
1666
+
1667
+ async function confirmDelete() {
1668
+ if (!turmaToDelete) return;
1669
+
1670
+ try {
1671
+ await request({
1672
+ url: `/lms/classes/${turmaToDelete.id}`,
1673
+ method: 'DELETE',
1674
+ });
1675
+ toast.success(t('toasts.turmaRemoved'));
1676
+ await refetchClasses();
1677
+ await refetchStats();
1678
+ notifyLmsDashboardUpdated();
1679
+ setTurmaToDelete(null);
1680
+ setDeleteDialogOpen(false);
1681
+ } catch {
1682
+ toast.error('Nao foi possivel excluir a turma.');
1683
+ }
557
1684
  }
558
1685
 
1686
+ const handleProfessorCreated = async (instructor: {
1687
+ id: number;
1688
+ personId: number;
1689
+ name: string;
1690
+ qualificationSlugs: string[];
1691
+ }) => {
1692
+ form.setValue('instructorId', instructor.id, {
1693
+ shouldDirty: true,
1694
+ shouldTouch: true,
1695
+ shouldValidate: true,
1696
+ });
1697
+ form.setValue('professor', instructor.name, {
1698
+ shouldDirty: true,
1699
+ shouldTouch: true,
1700
+ shouldValidate: true,
1701
+ });
1702
+
1703
+ await refetchProfessorOptions();
1704
+ await refetchClasses();
1705
+ };
1706
+
559
1707
  // ── KPIs ────────────────────────────────────────────────���─────────────────
560
1708
 
561
- const kpis = [
1709
+ const kpis: KpiCardItem[] = [
562
1710
  {
563
- label: t('kpis.totalClasses.label'),
564
- valor: turmas.length,
565
- sub: t('kpis.totalClasses.sub'),
1711
+ key: 'total-classes',
1712
+ title: t('kpis.totalClasses.label'),
1713
+ value: classStats?.totalClasses ?? totalItems,
1714
+ description: t('kpis.totalClasses.sub'),
566
1715
  icon: Users2,
567
- iconBg: 'bg-orange-100',
568
- iconColor: 'text-orange-600',
1716
+ iconContainerClassName: 'bg-orange-500/10 text-orange-700',
1717
+ accentClassName: 'from-orange-500/25 via-amber-500/10 to-transparent',
1718
+ layout: 'compact',
569
1719
  },
570
1720
  {
571
- label: t('kpis.inProgress.label'),
572
- valor: turmas.filter((t) => t.status === 'em_andamento').length,
573
- sub: t('kpis.inProgress.sub'),
1721
+ key: 'in-progress',
1722
+ title: t('kpis.inProgress.label'),
1723
+ value:
1724
+ classStats?.ongoingClasses ??
1725
+ turmas.filter((t) => t.status === 'em_andamento').length,
1726
+ description: t('kpis.inProgress.sub'),
574
1727
  icon: Clock,
575
- iconBg: 'bg-muted',
576
- iconColor: 'text-foreground',
1728
+ iconContainerClassName: 'bg-sky-500/10 text-sky-700',
1729
+ accentClassName: 'from-sky-500/25 via-blue-500/10 to-transparent',
1730
+ layout: 'compact',
577
1731
  },
578
1732
  {
579
- label: t('kpis.openVacancies.label'),
580
- valor: turmas
581
- .filter((t) => t.status === 'aberta')
582
- .reduce((a, t) => a + (t.vagas - t.matriculados), 0),
583
- sub: t('kpis.openVacancies.sub'),
1733
+ key: 'open-vacancies',
1734
+ title: t('kpis.openVacancies.label'),
1735
+ value:
1736
+ classStats?.openVacancies ??
1737
+ turmas
1738
+ .filter((t) => t.status === 'aberta')
1739
+ .reduce((a, t) => a + (t.vagas - t.matriculados), 0),
1740
+ description: t('kpis.openVacancies.sub'),
584
1741
  icon: Users,
585
- iconBg: 'bg-muted',
586
- iconColor: 'text-foreground',
1742
+ iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
1743
+ accentClassName: 'from-emerald-500/25 via-emerald-500/10 to-transparent',
1744
+ layout: 'compact',
587
1745
  },
588
1746
  {
589
- label: t('kpis.occupancyRate.label'),
590
- valor: `${Math.round((turmas.reduce((a, t) => a + t.matriculados / Math.max(t.vagas, 1), 0) / Math.max(turmas.length, 1)) * 100)}%`,
591
- sub: t('kpis.occupancyRate.sub'),
1747
+ key: 'occupancy-rate',
1748
+ title: t('kpis.occupancyRate.label'),
1749
+ value: `${
1750
+ classStats?.occupancyRate ??
1751
+ Math.round(
1752
+ (turmas.reduce(
1753
+ (a, t) => a + t.matriculados / Math.max(t.vagas, 1),
1754
+ 0
1755
+ ) /
1756
+ Math.max(turmas.length, 1)) *
1757
+ 100
1758
+ )
1759
+ }%`,
1760
+ description: t('kpis.occupancyRate.sub'),
592
1761
  icon: BarChart3,
593
- iconBg: 'bg-muted',
594
- iconColor: 'text-foreground',
1762
+ iconContainerClassName: 'bg-pink-500/10 text-pink-700',
1763
+ accentClassName: 'from-pink-500/25 via-pink-500/10 to-transparent',
1764
+ layout: 'compact',
595
1765
  },
596
1766
  ];
597
1767
 
1768
+ const handleNewClass = (): void => {
1769
+ const nextLocaleData: Record<string, { name: string }> = {};
1770
+ locales.forEach((locale: Locale) => {
1771
+ nextLocaleData[locale.code] = {
1772
+ name: '',
1773
+ };
1774
+ });
1775
+ void nextLocaleData;
1776
+ openCreateSheet();
1777
+ };
1778
+
598
1779
  // ── Render ────────────────────────────────────────────────────────────────
599
1780
 
600
1781
  return (
@@ -611,359 +1792,518 @@ export default function TurmasPage() {
611
1792
  label: t('breadcrumbs.classes'),
612
1793
  },
613
1794
  ]}
614
- actions={
615
- <Button onClick={openCreateSheet} className="gap-2">
616
- <Plus className="size-4" />
617
- {t('actions.createClass')}
618
- </Button>
619
- }
1795
+ actions={[
1796
+ {
1797
+ label: t('actions.createClass'),
1798
+ onClick: () => handleNewClass(),
1799
+ variant: 'default',
1800
+ },
1801
+ ]}
620
1802
  />
621
1803
 
622
- {/* KPIs */}
623
- <div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
624
- {loading
625
- ? Array.from({ length: 4 }).map((_, i) => (
626
- <Card key={i}>
627
- <CardContent className="p-4">
628
- <Skeleton className="mb-2 h-8 w-16" />
629
- <Skeleton className="h-4 w-28" />
630
- </CardContent>
631
- </Card>
632
- ))
633
- : kpis.map((kpi, i) => (
634
- <motion.div
635
- key={kpi.label}
636
- initial={{ opacity: 0, y: 12 }}
637
- animate={{ opacity: 1, y: 0 }}
638
- transition={{ delay: i * 0.07 }}
639
- >
640
- <Card className="overflow-hidden">
641
- <CardContent className="flex items-start justify-between p-5">
642
- <div>
643
- <p className="text-sm text-muted-foreground">
644
- {kpi.label}
645
- </p>
646
- <p className="mt-1 text-3xl font-bold tracking-tight">
647
- {kpi.valor}
648
- </p>
649
- <p className="mt-0.5 text-xs text-muted-foreground">
650
- {kpi.sub}
651
- </p>
652
- </div>
653
- <div
654
- className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
655
- >
656
- <kpi.icon className={`size-5 ${kpi.iconColor}`} />
657
- </div>
1804
+ <div className="space-y-4">
1805
+ {/* KPIs */}
1806
+ <div>
1807
+ {loading ? (
1808
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
1809
+ {Array.from({ length: 4 }).map((_, i) => (
1810
+ <Card key={i} className="overflow-hidden border-border/70 py-0">
1811
+ <div className="h-1 w-full bg-linear-to-r from-slate-300/70 via-slate-200 to-transparent" />
1812
+ <CardContent className="p-4">
1813
+ <Skeleton className="mb-2 h-8 w-16" />
1814
+ <Skeleton className="h-4 w-28" />
658
1815
  </CardContent>
659
1816
  </Card>
660
- </motion.div>
661
- ))}
662
- </div>
1817
+ ))}
1818
+ </div>
1819
+ ) : (
1820
+ <KpiCardsGrid items={kpis} columns={4} />
1821
+ )}
1822
+ </div>
663
1823
 
664
- {/* Search bar */}
665
- <form onSubmit={handleSearch} className="mb-6 mt-0">
666
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
667
- <div className="relative flex-1">
668
- <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
669
- <Input
670
- placeholder={t('filters.searchPlaceholder')}
671
- value={buscaInput}
672
- onChange={(e) => setBuscaInput(e.target.value)}
673
- className="pl-9"
674
- />
675
- </div>
676
- <div className="flex flex-wrap items-center gap-2">
677
- <Select
678
- value={filtroStatusInput}
679
- onValueChange={setFiltroStatusInput}
680
- >
681
- <SelectTrigger className="h-9 w-[140px] text-sm">
682
- <SelectValue placeholder={t('filters.status')} />
683
- </SelectTrigger>
684
- <SelectContent>
685
- <SelectItem value="todos">
686
- {t('filters.allStatuses')}
687
- </SelectItem>
688
- <SelectItem value="aberta">{t('status.open')}</SelectItem>
689
- <SelectItem value="em_andamento">
690
- {t('status.inProgress')}
691
- </SelectItem>
692
- <SelectItem value="concluida">
693
- {t('status.completed')}
694
- </SelectItem>
695
- <SelectItem value="cancelada">
696
- {t('status.cancelled')}
697
- </SelectItem>
698
- </SelectContent>
699
- </Select>
700
- <Select value={filtroTipoInput} onValueChange={setFiltroTipoInput}>
701
- <SelectTrigger className="h-9 w-[120px] text-sm">
702
- <SelectValue placeholder={t('filters.type')} />
703
- </SelectTrigger>
704
- <SelectContent>
705
- <SelectItem value="todos">{t('filters.allTypes')}</SelectItem>
706
- <SelectItem value="presencial">{t('type.inPerson')}</SelectItem>
707
- <SelectItem value="online">{t('type.online')}</SelectItem>
708
- <SelectItem value="hibrida">{t('type.hybrid')}</SelectItem>
709
- </SelectContent>
710
- </Select>
711
- <Select
712
- value={filtroCursoInput}
713
- onValueChange={setFiltroCursoInput}
714
- >
715
- <SelectTrigger className="h-9 w-40 text-sm">
716
- <SelectValue placeholder={t('filters.course')} />
717
- </SelectTrigger>
718
- <SelectContent>
719
- <SelectItem value="todos">{t('filters.allCourses')}</SelectItem>
720
- {uniqueCursos.map((c) => (
721
- <SelectItem key={c} value={c}>
722
- {c}
723
- </SelectItem>
724
- ))}
725
- </SelectContent>
726
- </Select>
727
- {hasActiveFilters && (
728
- <Button
729
- type="button"
730
- variant="ghost"
731
- size="sm"
732
- onClick={clearFilters}
733
- className="h-9 text-muted-foreground"
734
- >
735
- <X className="mr-1 size-3.5" /> {t('filters.clear')}
736
- </Button>
737
- )}
738
- <Button type="submit" size="sm" className="h-9 gap-2">
739
- <Search className="size-3.5" /> {t('filters.search')}
740
- </Button>
1824
+ {/* Search bar */}
1825
+ <div className="space-y-1">
1826
+ <SearchBar
1827
+ searchQuery={buscaInput}
1828
+ onSearchChange={setBuscaInput}
1829
+ onSearch={() => setBuscaDebounced(buscaInput.trim())}
1830
+ placeholder={t('filters.searchPlaceholder')}
1831
+ controls={[
1832
+ {
1833
+ id: 'status-filter',
1834
+ type: 'select',
1835
+ value: filtroStatusInput,
1836
+ onChange: setFiltroStatusInput,
1837
+ placeholder: t('filters.status'),
1838
+ options: [
1839
+ { value: 'todos', label: t('filters.allStatuses') },
1840
+ { value: 'aberta', label: t('status.open') },
1841
+ { value: 'em_andamento', label: t('status.inProgress') },
1842
+ { value: 'concluida', label: t('status.completed') },
1843
+ { value: 'cancelada', label: t('status.cancelled') },
1844
+ ],
1845
+ },
1846
+ {
1847
+ id: 'type-filter',
1848
+ type: 'select',
1849
+ value: filtroTipoInput,
1850
+ onChange: setFiltroTipoInput,
1851
+ placeholder: t('filters.type'),
1852
+ options: [
1853
+ { value: 'todos', label: t('filters.allTypes') },
1854
+ { value: 'presencial', label: t('type.inPerson') },
1855
+ { value: 'online', label: t('type.online') },
1856
+ { value: 'hibrida', label: t('type.hybrid') },
1857
+ ],
1858
+ },
1859
+ {
1860
+ id: 'course-filter',
1861
+ type: 'select',
1862
+ value: filtroCursoInput,
1863
+ onChange: setFiltroCursoInput,
1864
+ placeholder: t('filters.course'),
1865
+ options: [
1866
+ { value: 'todos', label: t('filters.allCourses') },
1867
+ ...uniqueCursos.map((c) => ({ value: c, label: c })),
1868
+ ],
1869
+ className: 'sm:w-56',
1870
+ },
1871
+ ]}
1872
+ afterSearchButton={
1873
+ <ViewModeToggle
1874
+ viewMode={viewMode}
1875
+ onViewModeChange={setViewMode}
1876
+ listLabel={t('viewMode.list')}
1877
+ cardsLabel={t('viewMode.cards')}
1878
+ />
1879
+ }
1880
+ />
1881
+ <div className="flex items-center justify-between gap-3">
1882
+ <div>
1883
+ {hasActiveFilters ? (
1884
+ <Button
1885
+ type="button"
1886
+ variant="ghost"
1887
+ size="sm"
1888
+ onClick={clearFilters}
1889
+ className="h-8 px-2 text-muted-foreground"
1890
+ >
1891
+ <X className="mr-1 size-3.5" />
1892
+ {t('filters.clear')}
1893
+ </Button>
1894
+ ) : null}
1895
+ </div>
1896
+ {isRefreshing ? (
1897
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
1898
+ ) : null}
741
1899
  </div>
742
1900
  </div>
743
- </form>
744
-
745
- {/* Cards grid */}
746
- {loading ? (
747
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
748
- {Array.from({ length: 6 }).map((_, i) => (
749
- <Card key={i} className="overflow-hidden">
750
- <CardContent className="p-5">
751
- <Skeleton className="mb-3 h-5 w-3/4" />
752
- <Skeleton className="mb-2 h-4 w-1/2" />
753
- <Skeleton className="mb-4 h-2 w-full rounded-full" />
754
- <div className="flex gap-2">
755
- <Skeleton className="h-6 w-20 rounded-full" />
756
- <Skeleton className="h-6 w-16 rounded-full" />
757
- </div>
758
- </CardContent>
759
- </Card>
760
- ))}
761
- </div>
762
- ) : filteredTurmas.length === 0 ? (
763
- <EmptyState
764
- icon={<Users className="h-12 w-12" />}
765
- title={t('empty.title')}
766
- description={t('empty.description')}
767
- actionLabel={t('empty.action')}
768
- onAction={openCreateSheet}
769
- actionIcon={<Plus className="mr-2 h-4 w-4" />}
770
- />
771
- ) : (
772
- <motion.div
773
- className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
774
- variants={stagger}
775
- initial="hidden"
776
- animate="show"
777
- >
778
- {paginatedTurmas.map((turma) => {
779
- const ocupacao = Math.round(
780
- (turma.matriculados / Math.max(turma.vagas, 1)) * 100
781
- );
782
- const TipoIcon = (TIPO_ICON[turma.tipo] || Monitor) as React.FC<{
783
- className?: string;
784
- }>;
785
-
786
- return (
787
- <motion.div key={turma.id} variants={fadeUp}>
788
- <Card
789
- className="group relative cursor-pointer overflow-hidden border-0 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-1"
790
- onClick={() => handleCardClick(turma)}
791
- title={t('cards.tooltip')}
792
- >
793
- {/* Top accent */}
794
- <div className="h-1 w-full bg-foreground" />
795
1901
 
1902
+ {/* Classes list */}
1903
+ {loading ? (
1904
+ viewMode === 'cards' ? (
1905
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
1906
+ {Array.from({ length: 6 }).map((_, i) => (
1907
+ <Card key={i} className="overflow-hidden border-border/70">
796
1908
  <CardContent className="p-5">
797
- {/* Header with Icon + Title + Actions */}
798
- <div className="mb-4 flex items-start gap-3">
799
- {/* Type icon */}
800
- <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted border">
801
- <TipoIcon className="size-6 text-foreground" />
802
- </div>
803
- <div className="min-w-0 flex-1">
804
- <div className="mb-1 flex items-start justify-between gap-2">
805
- <h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
806
- {turma.curso}
807
- </h3>
808
- <DropdownMenu>
809
- <DropdownMenuTrigger asChild>
810
- <Button
811
- variant="ghost"
812
- size="icon"
813
- className="size-8 shrink-0 -mr-2 -mt-1"
814
- onClick={(e) => e.stopPropagation()}
815
- aria-label={t('cards.actions.label')}
816
- >
817
- <MoreHorizontal className="size-4" />
818
- </Button>
819
- </DropdownMenuTrigger>
820
- <DropdownMenuContent align="end" className="w-48">
821
- <DropdownMenuItem
822
- onClick={(e) => {
823
- e.stopPropagation();
824
- router.push(`/lms/classes/${turma.id}`);
825
- }}
826
- >
827
- <Eye className="mr-2 size-4" />{' '}
828
- {t('cards.actions.viewDetails')}
829
- </DropdownMenuItem>
830
- <DropdownMenuItem
831
- onClick={(e) => openEditSheet(turma, e)}
832
- >
833
- <Pencil className="mr-2 size-4" />{' '}
834
- {t('cards.actions.edit')}
835
- </DropdownMenuItem>
836
- <DropdownMenuSeparator />
837
- <DropdownMenuItem
838
- className="text-destructive focus:text-destructive"
839
- onClick={(e) => {
840
- e.stopPropagation();
841
- setTurmaToDelete(turma);
842
- setDeleteDialogOpen(true);
843
- }}
844
- >
845
- <Trash2 className="mr-2 size-4" />{' '}
846
- {t('cards.actions.delete')}
847
- </DropdownMenuItem>
848
- </DropdownMenuContent>
849
- </DropdownMenu>
850
- </div>
851
- <p className="text-xs text-muted-foreground">
852
- <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
853
- {turma.codigo}
854
- </code>
855
- <span className="mx-1.5 text-muted-foreground/50">
856
- |
857
- </span>
858
- <span>{turma.professor}</span>
859
- </p>
860
- </div>
1909
+ <Skeleton className="mb-3 h-5 w-3/4" />
1910
+ <Skeleton className="mb-2 h-4 w-1/2" />
1911
+ <Skeleton className="mb-4 h-2 w-full rounded-full" />
1912
+ <div className="flex gap-2">
1913
+ <Skeleton className="h-6 w-20 rounded-full" />
1914
+ <Skeleton className="h-6 w-16 rounded-full" />
861
1915
  </div>
1916
+ </CardContent>
1917
+ </Card>
1918
+ ))}
1919
+ </div>
1920
+ ) : (
1921
+ <div className="overflow-hidden rounded-xl border border-border/70">
1922
+ <Table>
1923
+ <TableHeader>
1924
+ <TableRow>
1925
+ <TableHead>{t('table.headers.course')}</TableHead>
1926
+ <TableHead>{t('table.headers.status')}</TableHead>
1927
+ <TableHead>{t('table.headers.type')}</TableHead>
1928
+ <TableHead>{t('table.headers.period')}</TableHead>
1929
+ <TableHead>{t('table.headers.schedule')}</TableHead>
1930
+ <TableHead className="text-right">
1931
+ {t('cards.enrolled')}
1932
+ </TableHead>
1933
+ <TableHead className="w-12" />
1934
+ </TableRow>
1935
+ </TableHeader>
1936
+ <TableBody>
1937
+ {Array.from({ length: 6 }).map((_, i) => (
1938
+ <TableRow key={i}>
1939
+ <TableCell>
1940
+ <div className="space-y-1.5">
1941
+ <Skeleton className="h-4 w-44" />
1942
+ <Skeleton className="h-3 w-28" />
1943
+ </div>
1944
+ </TableCell>
1945
+ <TableCell>
1946
+ <Skeleton className="h-5 w-20 rounded-full" />
1947
+ </TableCell>
1948
+ <TableCell>
1949
+ <Skeleton className="h-5 w-20 rounded-full" />
1950
+ </TableCell>
1951
+ <TableCell>
1952
+ <Skeleton className="h-4 w-20" />
1953
+ </TableCell>
1954
+ <TableCell>
1955
+ <Skeleton className="h-4 w-20" />
1956
+ </TableCell>
1957
+ <TableCell className="text-right">
1958
+ <Skeleton className="ml-auto h-4 w-10" />
1959
+ </TableCell>
1960
+ <TableCell>
1961
+ <Skeleton className="ml-auto size-8 rounded-md" />
1962
+ </TableCell>
1963
+ </TableRow>
1964
+ ))}
1965
+ </TableBody>
1966
+ </Table>
1967
+ </div>
1968
+ )
1969
+ ) : visibleTurmas.length === 0 ? (
1970
+ <EmptyState
1971
+ icon={<Users className="h-12 w-12" />}
1972
+ title={t('empty.title')}
1973
+ description={t('empty.description')}
1974
+ actionLabel={t('empty.action')}
1975
+ onAction={openCreateSheet}
1976
+ actionIcon={<Plus className="mr-2 size-4" />}
1977
+ />
1978
+ ) : viewMode === 'cards' ? (
1979
+ <motion.div
1980
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
1981
+ variants={stagger}
1982
+ initial="hidden"
1983
+ animate="show"
1984
+ >
1985
+ {visibleTurmas.map((turma) => {
1986
+ const ocupacao = Math.round(
1987
+ (turma.matriculados / Math.max(turma.vagas, 1)) * 100
1988
+ );
1989
+ const TipoIcon = TIPO_ICON[turma.tipo] || Monitor;
1990
+ const scheduleLabel = formatSchedule(
1991
+ turma.horarioInicio,
1992
+ turma.horarioFim
1993
+ );
862
1994
 
863
- {/* Badges */}
864
- <div className="mb-4 flex flex-wrap items-center gap-1.5">
865
- <Badge
866
- variant={STATUS_VARIANT[turma.status]}
867
- className="text-[11px]"
868
- >
869
- {t(`status.${turma.status}`)}
870
- </Badge>
871
- <span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-medium border bg-muted text-foreground">
872
- <TipoIcon className="size-3" />
873
- {t(`type.${turma.tipo}`)}
874
- </span>
875
- </div>
1995
+ return (
1996
+ <motion.div key={turma.id} variants={fadeUp}>
1997
+ <Card
1998
+ className="group relative cursor-pointer overflow-hidden border-border/70 shadow-sm transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md"
1999
+ onClick={() => handleCardClick(turma)}
2000
+ title={t('cards.tooltip')}
2001
+ >
2002
+ <div
2003
+ className="absolute inset-x-0 top-0 h-1"
2004
+ style={{
2005
+ backgroundColor: turma.primaryColor || '#1D4ED8',
2006
+ }}
2007
+ />
2008
+ <CardContent className="p-5">
2009
+ <div className="mb-4 flex items-start gap-3">
2010
+ <div className="flex size-12 shrink-0 items-center justify-center rounded-xl border bg-muted">
2011
+ <TipoIcon className="size-6 text-foreground" />
2012
+ </div>
2013
+ <div className="min-w-0 flex-1">
2014
+ <div className="mb-1 flex items-start justify-between gap-2">
2015
+ <h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
2016
+ {turma.curso}
2017
+ </h3>
2018
+ <DropdownMenu>
2019
+ <DropdownMenuTrigger asChild>
2020
+ <Button
2021
+ variant="ghost"
2022
+ size="icon"
2023
+ className="size-8 shrink-0 -mr-2 -mt-1"
2024
+ onClick={(e) => e.stopPropagation()}
2025
+ aria-label={t('cards.actions.label')}
2026
+ >
2027
+ <MoreHorizontal className="size-4" />
2028
+ </Button>
2029
+ </DropdownMenuTrigger>
2030
+ <DropdownMenuContent align="end" className="w-48">
2031
+ <DropdownMenuItem
2032
+ onClick={(e) => {
2033
+ e.stopPropagation();
2034
+ router.push(`/lms/classes/${turma.id}`);
2035
+ }}
2036
+ >
2037
+ <Eye className="mr-2 size-4" />{' '}
2038
+ {t('cards.actions.viewDetails')}
2039
+ </DropdownMenuItem>
2040
+ <DropdownMenuItem
2041
+ onClick={(e) => openEditSheet(turma, e)}
2042
+ >
2043
+ <Pencil className="mr-2 size-4" />{' '}
2044
+ {t('cards.actions.edit')}
2045
+ </DropdownMenuItem>
2046
+ <DropdownMenuSeparator />
2047
+ <DropdownMenuItem
2048
+ className="text-destructive focus:text-destructive"
2049
+ onClick={(e) => openDeleteDialog(turma, e)}
2050
+ >
2051
+ <Trash2 className="mr-2 size-4" />{' '}
2052
+ {t('cards.actions.delete')}
2053
+ </DropdownMenuItem>
2054
+ </DropdownMenuContent>
2055
+ </DropdownMenu>
2056
+ </div>
2057
+ <p className="text-xs text-muted-foreground">
2058
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
2059
+ {turma.codigo}
2060
+ </code>
2061
+ <span className="mx-1.5 text-muted-foreground/50">
2062
+ |
2063
+ </span>
2064
+ <span>{turma.professor}</span>
2065
+ </p>
2066
+ </div>
2067
+ </div>
876
2068
 
877
- {/* Occupancy progress */}
878
- <div className="mb-4 rounded-lg bg-muted/40 p-3">
879
- <div className="mb-2 flex items-center justify-between text-sm">
880
- <span className="text-muted-foreground">
881
- {t('cards.occupancy')}
2069
+ <div className="mb-4 flex flex-wrap items-center gap-1.5">
2070
+ <Badge
2071
+ variant={STATUS_VARIANT[turma.status]}
2072
+ className="text-[11px]"
2073
+ >
2074
+ {t(`status.${turma.status}`)}
2075
+ </Badge>
2076
+ <span className="inline-flex items-center gap-1 rounded-full border bg-muted px-2.5 py-0.5 text-[11px] font-medium text-foreground">
2077
+ <TipoIcon className="size-3" />
2078
+ {t(`type.${turma.tipo}`)}
882
2079
  </span>
883
- <span className="font-semibold">
884
- {turma.matriculados}/{turma.vagas}
885
- </span>
886
- </div>
887
- <div className="relative h-2 w-full overflow-hidden rounded-full bg-muted">
888
- <div
889
- className="h-full rounded-full transition-all duration-500 bg-foreground"
890
- style={{ width: `${Math.min(ocupacao, 100)}%` }}
891
- />
892
2080
  </div>
893
- <div className="mt-1.5 flex justify-between text-[11px]">
894
- <span className="text-muted-foreground">
895
- {ocupacao}% {t('cards.occupied')}
896
- </span>
897
- <span className="font-medium text-foreground">
898
- {Math.max(turma.vagas - turma.matriculados, 0)}{' '}
899
- {t('cards.freeVacancies')}
900
- </span>
2081
+
2082
+ <div className="mb-4 rounded-lg bg-muted/40 p-3">
2083
+ <div className="mb-2 flex items-center justify-between text-sm">
2084
+ <span className="text-muted-foreground">
2085
+ {t('cards.occupancy')}
2086
+ </span>
2087
+ <span className="font-semibold">
2088
+ {turma.matriculados}/{turma.vagas}
2089
+ </span>
2090
+ </div>
2091
+ <div className="relative h-2 w-full overflow-hidden rounded-full bg-muted">
2092
+ <div
2093
+ className="h-full rounded-full bg-primary transition-all duration-500"
2094
+ style={{ width: `${Math.min(ocupacao, 100)}%` }}
2095
+ />
2096
+ </div>
2097
+ <div className="mt-1.5 flex justify-between text-[11px]">
2098
+ <span className="text-muted-foreground">
2099
+ {ocupacao}% {t('cards.occupied')}
2100
+ </span>
2101
+ <span className="font-medium text-foreground">
2102
+ {Math.max(turma.vagas - turma.matriculados, 0)}{' '}
2103
+ {t('cards.freeVacancies')}
2104
+ </span>
2105
+ </div>
901
2106
  </div>
902
- </div>
903
2107
 
904
- {/* Date & time cards */}
905
- <div className="mb-4 grid grid-cols-2 gap-2">
906
- <div className="rounded-lg border bg-background p-2.5">
907
- <div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
908
- <Calendar className="size-3" /> {t('cards.period')}
2108
+ <div className="mb-4 grid grid-cols-2 gap-2">
2109
+ <div className="rounded-lg border bg-background p-2.5">
2110
+ <div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
2111
+ <CalendarIcon className="size-3" />{' '}
2112
+ {t('cards.period')}
2113
+ </div>
2114
+ <p className="text-xs font-medium">
2115
+ {formatDateLocalized(
2116
+ turma.dataInicio,
2117
+ getSettingValue,
2118
+ currentLocaleCode
2119
+ )}
2120
+ </p>
2121
+ <p className="text-[11px] text-muted-foreground">
2122
+ {t('cards.until')}{' '}
2123
+ {formatDateLocalized(
2124
+ turma.dataFim,
2125
+ getSettingValue,
2126
+ currentLocaleCode
2127
+ )}
2128
+ </p>
2129
+ </div>
2130
+ <div className="rounded-lg border bg-background p-2.5">
2131
+ <div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
2132
+ <Clock className="size-3" /> {t('cards.schedule')}
2133
+ </div>
2134
+ <p className="text-xs font-medium">{scheduleLabel}</p>
909
2135
  </div>
910
- <p className="text-xs font-medium">
911
- {formatDate(turma.dataInicio)}
912
- </p>
913
- <p className="text-[11px] text-muted-foreground">
914
- {t('cards.until')} {formatDate(turma.dataFim)}
915
- </p>
916
2136
  </div>
917
- <div className="rounded-lg border bg-background p-2.5">
918
- <div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
919
- <Clock className="size-3" /> {t('cards.schedule')}
2137
+
2138
+ <div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5">
2139
+ <div className="flex items-center gap-1.5">
2140
+ <Users className="size-4 text-muted-foreground" />
2141
+ <span className="text-sm font-medium">
2142
+ {turma.matriculados}
2143
+ </span>
2144
+ <span className="text-xs text-muted-foreground">
2145
+ {t('cards.enrolled')}
2146
+ </span>
920
2147
  </div>
921
- <p className="text-xs font-medium">{turma.horario}</p>
922
2148
  </div>
923
- </div>
2149
+ </CardContent>
2150
+ </Card>
2151
+ </motion.div>
2152
+ );
2153
+ })}
2154
+ </motion.div>
2155
+ ) : (
2156
+ <div className="overflow-hidden rounded-xl border border-border/70">
2157
+ <Table>
2158
+ <TableHeader>
2159
+ <TableRow>
2160
+ <TableHead>{t('table.headers.course')}</TableHead>
2161
+ <TableHead>{t('table.headers.status')}</TableHead>
2162
+ <TableHead>{t('table.headers.type')}</TableHead>
2163
+ <TableHead>{t('table.headers.period')}</TableHead>
2164
+ <TableHead>{t('table.headers.schedule')}</TableHead>
2165
+ <TableHead>{t('cards.occupancy')}</TableHead>
2166
+ <TableHead className="text-right">
2167
+ {t('cards.enrolled')}
2168
+ </TableHead>
2169
+ <TableHead className="w-12" />
2170
+ </TableRow>
2171
+ </TableHeader>
2172
+ <TableBody>
2173
+ {visibleTurmas.map((turma) => {
2174
+ const ocupacao = Math.round(
2175
+ (turma.matriculados / Math.max(turma.vagas, 1)) * 100
2176
+ );
2177
+ const TipoIcon = TIPO_ICON[turma.tipo] || Monitor;
2178
+ const scheduleLabel = formatSchedule(
2179
+ turma.horarioInicio,
2180
+ turma.horarioFim
2181
+ );
924
2182
 
925
- {/* Footer stats */}
926
- <div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5">
927
- <div className="flex items-center gap-1.5">
928
- <Users className="size-4 text-muted-foreground" />
929
- <span className="text-sm font-medium">
930
- {turma.matriculados}
931
- </span>
932
- <span className="text-xs text-muted-foreground">
933
- {t('cards.enrolled')}
2183
+ return (
2184
+ <TableRow
2185
+ key={turma.id}
2186
+ className="cursor-pointer"
2187
+ onClick={() => handleCardClick(turma)}
2188
+ title={t('cards.tooltip')}
2189
+ >
2190
+ <TableCell>
2191
+ <div className="min-w-0">
2192
+ <p className="truncate font-semibold text-foreground">
2193
+ {turma.curso}
2194
+ </p>
2195
+ <p className="mt-1 text-xs text-muted-foreground">
2196
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
2197
+ {turma.codigo}
2198
+ </code>
2199
+ <span className="mx-1.5 text-muted-foreground/50">
2200
+ |
2201
+ </span>
2202
+ <span>{turma.professor}</span>
2203
+ </p>
2204
+ </div>
2205
+ </TableCell>
2206
+ <TableCell>
2207
+ <Badge
2208
+ variant={STATUS_VARIANT[turma.status]}
2209
+ className="text-[11px]"
2210
+ >
2211
+ {t(`status.${turma.status}`)}
2212
+ </Badge>
2213
+ </TableCell>
2214
+ <TableCell>
2215
+ <span className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground">
2216
+ <TipoIcon className="size-3" />
2217
+ {t(`type.${turma.tipo}`)}
934
2218
  </span>
935
- </div>
936
- </div>
937
- </CardContent>
938
- </Card>
939
- </motion.div>
940
- );
941
- })}
942
- </motion.div>
943
- )}
944
-
945
- {/* Pagination footer */}
946
- {!loading && filteredTurmas.length > 0 && (
947
- <div className="mt-6">
948
- <PaginationFooter
949
- currentPage={safePage}
950
- pageSize={pageSize}
951
- totalItems={filteredTurmas.length}
952
- onPageChange={setCurrentPage}
953
- onPageSizeChange={(nextSize) => {
954
- setPageSize(nextSize);
955
- setCurrentPage(1);
956
- }}
957
- pageSizeOptions={PAGE_SIZES}
958
- />
959
- </div>
960
- )}
2219
+ </TableCell>
2220
+ <TableCell>
2221
+ {formatDateLocalized(
2222
+ turma.dataInicio,
2223
+ getSettingValue,
2224
+ currentLocaleCode
2225
+ )}{' '}
2226
+ {t('cards.until')}{' '}
2227
+ {formatDateLocalized(
2228
+ turma.dataFim,
2229
+ getSettingValue,
2230
+ currentLocaleCode
2231
+ )}
2232
+ </TableCell>
2233
+ <TableCell>{scheduleLabel}</TableCell>
2234
+ <TableCell>{ocupacao}%</TableCell>
2235
+ <TableCell className="text-right font-medium">
2236
+ {turma.matriculados}
2237
+ </TableCell>
2238
+ <TableCell onClick={(e) => e.stopPropagation()}>
2239
+ <DropdownMenu>
2240
+ <DropdownMenuTrigger asChild>
2241
+ <Button
2242
+ variant="ghost"
2243
+ size="icon"
2244
+ className="ml-auto size-8"
2245
+ aria-label={t('cards.actions.label')}
2246
+ >
2247
+ <MoreHorizontal className="size-4" />
2248
+ </Button>
2249
+ </DropdownMenuTrigger>
2250
+ <DropdownMenuContent align="end" className="w-48">
2251
+ <DropdownMenuItem
2252
+ onClick={() =>
2253
+ router.push(`/lms/classes/${turma.id}`)
2254
+ }
2255
+ >
2256
+ <Eye className="mr-2 size-4" />{' '}
2257
+ {t('cards.actions.viewDetails')}
2258
+ </DropdownMenuItem>
2259
+ <DropdownMenuItem
2260
+ onClick={() => openEditSheet(turma)}
2261
+ >
2262
+ <Pencil className="mr-2 size-4" />{' '}
2263
+ {t('cards.actions.edit')}
2264
+ </DropdownMenuItem>
2265
+ <DropdownMenuSeparator />
2266
+ <DropdownMenuItem
2267
+ className="text-destructive focus:text-destructive"
2268
+ onClick={(e) => openDeleteDialog(turma, e)}
2269
+ >
2270
+ <Trash2 className="mr-2 size-4" />{' '}
2271
+ {t('cards.actions.delete')}
2272
+ </DropdownMenuItem>
2273
+ </DropdownMenuContent>
2274
+ </DropdownMenu>
2275
+ </TableCell>
2276
+ </TableRow>
2277
+ );
2278
+ })}
2279
+ </TableBody>
2280
+ </Table>
2281
+ </div>
2282
+ )}
2283
+
2284
+ {/* Pagination footer */}
2285
+ {!loading && visibleTurmas.length > 0 && (
2286
+ <div className="mt-6">
2287
+ <PaginationFooter
2288
+ currentPage={currentPage}
2289
+ pageSize={pageSize}
2290
+ totalItems={totalItems}
2291
+ onPageChange={setCurrentPage}
2292
+ onPageSizeChange={(value) => {
2293
+ setPageSize(value);
2294
+ setCurrentPage(1);
2295
+ }}
2296
+ pageSizeOptions={PAGE_SIZES}
2297
+ />
2298
+ </div>
2299
+ )}
2300
+ </div>
961
2301
 
962
2302
  {/* Sheet */}
963
2303
  <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
964
2304
  <SheetContent
965
2305
  side="right"
966
- className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
2306
+ className="flex w-full flex-col overflow-y-auto sm:max-w-2xl"
967
2307
  >
968
2308
  <SheetHeader className="shrink-0">
969
2309
  <SheetTitle>
@@ -973,48 +2313,422 @@ export default function TurmasPage() {
973
2313
  </SheetHeader>
974
2314
  <form
975
2315
  onSubmit={form.handleSubmit(onSubmit)}
976
- className="flex flex-1 flex-col px-4 gap-4 py-6"
2316
+ className="flex flex-1 flex-col gap-5 px-4 py-6"
977
2317
  >
978
- <Field>
979
- <FieldLabel htmlFor="codigo">
980
- {t('form.fields.code.label')}{' '}
981
- <span className="text-destructive">*</span>
982
- </FieldLabel>
983
- <Input
984
- id="codigo"
985
- placeholder={t('form.fields.code.placeholder')}
986
- {...form.register('codigo')}
987
- />
988
- <FieldError>{form.formState.errors.codigo?.message}</FieldError>
989
- </Field>
990
- <Field>
991
- <FieldLabel>
992
- {t('form.fields.course.label')}{' '}
993
- <span className="text-destructive">*</span>
994
- </FieldLabel>
995
- <Controller
996
- name="curso"
997
- control={form.control}
998
- render={({ field }) => (
999
- <Select onValueChange={field.onChange} value={field.value}>
1000
- <SelectTrigger>
1001
- <SelectValue
1002
- placeholder={t('form.fields.course.placeholder')}
2318
+ <div className="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
2319
+ <Field>
2320
+ <FieldLabel htmlFor="codigo">
2321
+ {t('form.fields.code.label')}
2322
+ </FieldLabel>
2323
+ <Input
2324
+ id="codigo"
2325
+ value={watchedFormValues.codigo ?? ''}
2326
+ readOnly
2327
+ className="uppercase"
2328
+ />
2329
+ <FieldDescription>
2330
+ Codigo gerado automaticamente pelo sistema.
2331
+ </FieldDescription>
2332
+ <FieldError>{form.formState.errors.codigo?.message}</FieldError>
2333
+ </Field>
2334
+
2335
+ <Field>
2336
+ <FieldLabel>
2337
+ {t('form.fields.course.label')}{' '}
2338
+ <span className="text-destructive">*</span>
2339
+ </FieldLabel>
2340
+ <div className="flex items-end gap-2">
2341
+ <div className="min-w-0 flex-1">
2342
+ <EntityPicker<{ id: number; title: string }, TurmaForm>
2343
+ form={form}
2344
+ name="courseId"
2345
+ valueType="number"
2346
+ placeholder={t('form.fields.course.placeholder')}
2347
+ entityLabel={t('form.fields.course.label')}
2348
+ initialSelectedLabel={selectedCourseTitle}
2349
+ searchPlaceholder={t('form.fields.course.placeholder')}
2350
+ emptyStateDescription="Nenhum curso encontrado."
2351
+ loadingLabel="Carregando cursos..."
2352
+ noResultsLabel="Nenhum curso encontrado."
2353
+ showCreateButton={false}
2354
+ onChange={(value, option) => {
2355
+ const courseId =
2356
+ typeof value === 'number' ? value : undefined;
2357
+ const courseTitle =
2358
+ option && typeof option.title === 'string'
2359
+ ? option.title
2360
+ : '';
2361
+
2362
+ form.setValue('courseId', courseId, {
2363
+ shouldDirty: true,
2364
+ shouldTouch: true,
2365
+ shouldValidate: true,
2366
+ });
2367
+ form.setValue('curso', courseTitle, {
2368
+ shouldDirty: true,
2369
+ shouldTouch: true,
2370
+ shouldValidate: false,
2371
+ });
2372
+ }}
2373
+ loadOptions={async ({ page, pageSize, search }) => {
2374
+ const response = await request<ApiCourseList>({
2375
+ url: '/lms/courses',
2376
+ method: 'GET',
2377
+ params: {
2378
+ page,
2379
+ pageSize,
2380
+ ...(search.trim() ? { search: search.trim() } : {}),
2381
+ },
2382
+ });
2383
+
2384
+ return {
2385
+ items: response.data?.data ?? [],
2386
+ hasMore: page < (response.data?.lastPage ?? 1),
2387
+ };
2388
+ }}
2389
+ getOptionValue={(option) => option.id}
2390
+ getOptionLabel={(option) => option.title}
2391
+ />
2392
+ </div>
2393
+
2394
+ <Button
2395
+ type="button"
2396
+ variant="outline"
2397
+ size="icon"
2398
+ className="shrink-0"
2399
+ onClick={openCourseCreateSheet}
2400
+ aria-label="Cadastrar novo curso"
2401
+ >
2402
+ <Plus className="h-4 w-4" />
2403
+ </Button>
2404
+ </div>
2405
+ </Field>
2406
+ </div>
2407
+
2408
+ <div className="rounded-lg border border-border/70 p-4">
2409
+ <div className="grid gap-4">
2410
+ <Field>
2411
+ <FieldLabel>
2412
+ {t('form.fields.startDate.label')} /{' '}
2413
+ {t('form.fields.endDate.label')}{' '}
2414
+ <span className="text-destructive">*</span>
2415
+ </FieldLabel>
2416
+ <Popover open={dateRangeOpen} onOpenChange={setDateRangeOpen}>
2417
+ <PopoverTrigger asChild>
2418
+ <Button
2419
+ type="button"
2420
+ variant="outline"
2421
+ className={`w-full justify-start text-left font-normal ${
2422
+ !watchedFormValues.dataInicio ||
2423
+ !watchedFormValues.dataFim
2424
+ ? 'text-muted-foreground'
2425
+ : ''
2426
+ }`}
2427
+ >
2428
+ <CalendarIcon className="mr-2 size-4" />
2429
+ {formatDateRangeLabel(
2430
+ watchedFormValues.dataInicio,
2431
+ watchedFormValues.dataFim,
2432
+ getSettingValue,
2433
+ currentLocaleCode
2434
+ ) || t('form.fields.startDate.placeholder')}
2435
+ </Button>
2436
+ </PopoverTrigger>
2437
+ <PopoverContent className="w-auto p-0" align="start">
2438
+ <Calendar
2439
+ mode="range"
2440
+ numberOfMonths={2}
2441
+ selected={dateRangeDraft}
2442
+ onSelect={(range) => {
2443
+ setDateRangeDraft(range);
2444
+
2445
+ if (!range?.from || !range?.to) {
2446
+ form.setValue('dataInicio', '', {
2447
+ shouldDirty: true,
2448
+ shouldTouch: true,
2449
+ shouldValidate: true,
2450
+ });
2451
+ form.setValue('dataFim', '', {
2452
+ shouldDirty: true,
2453
+ shouldTouch: true,
2454
+ shouldValidate: true,
2455
+ });
2456
+ return;
2457
+ }
2458
+
2459
+ form.setValue(
2460
+ 'dataInicio',
2461
+ format(range.from, 'yyyy-MM-dd'),
2462
+ {
2463
+ shouldDirty: true,
2464
+ shouldTouch: true,
2465
+ shouldValidate: true,
2466
+ }
2467
+ );
2468
+ form.setValue(
2469
+ 'dataFim',
2470
+ format(range.to, 'yyyy-MM-dd'),
2471
+ {
2472
+ shouldDirty: true,
2473
+ shouldTouch: true,
2474
+ shouldValidate: true,
2475
+ }
2476
+ );
2477
+ }}
2478
+ initialFocus
1003
2479
  />
1004
- </SelectTrigger>
1005
- <SelectContent>
1006
- {CURSOS.map((c) => (
1007
- <SelectItem key={c} value={c}>
1008
- {c}
2480
+ </PopoverContent>
2481
+ </Popover>
2482
+ <FieldError>
2483
+ {form.formState.errors.dataInicio?.message ||
2484
+ form.formState.errors.dataFim?.message}
2485
+ </FieldError>
2486
+ </Field>
2487
+
2488
+ <div className="grid gap-4 md:grid-cols-2">
2489
+ <Field>
2490
+ <FieldLabel htmlFor="horarioInicio">
2491
+ {t('form.fields.startTime.label')}{' '}
2492
+ <span className="text-destructive">*</span>
2493
+ </FieldLabel>
2494
+ <Controller
2495
+ name="horarioInicio"
2496
+ control={form.control}
2497
+ render={({ field }) => (
2498
+ <Select
2499
+ onValueChange={(value) => {
2500
+ field.onChange(value);
2501
+ }}
2502
+ value={field.value}
2503
+ >
2504
+ <SelectTrigger id="horarioInicio">
2505
+ <SelectValue
2506
+ placeholder={t(
2507
+ 'form.fields.startTime.placeholder'
2508
+ )}
2509
+ />
2510
+ </SelectTrigger>
2511
+ <SelectContent>
2512
+ {TIME_OPTIONS.map((time) => (
2513
+ <SelectItem key={time} value={time}>
2514
+ {time}
2515
+ </SelectItem>
2516
+ ))}
2517
+ </SelectContent>
2518
+ </Select>
2519
+ )}
2520
+ />
2521
+ <FieldDescription>
2522
+ Escolha o horario de inicio na lista para preencher mais
2523
+ rapido.
2524
+ </FieldDescription>
2525
+ <FieldError>
2526
+ {form.formState.errors.horarioInicio?.message}
2527
+ </FieldError>
2528
+ </Field>
2529
+
2530
+ <Field>
2531
+ <FieldLabel htmlFor="horarioFim">
2532
+ {t('form.fields.endTime.label')}{' '}
2533
+ <span className="text-destructive">*</span>
2534
+ </FieldLabel>
2535
+ <Controller
2536
+ name="horarioFim"
2537
+ control={form.control}
2538
+ render={({ field }) => (
2539
+ <Select
2540
+ onValueChange={field.onChange}
2541
+ value={field.value}
2542
+ >
2543
+ <SelectTrigger id="horarioFim">
2544
+ <SelectValue
2545
+ placeholder={t('form.fields.endTime.placeholder')}
2546
+ />
2547
+ </SelectTrigger>
2548
+ <SelectContent>
2549
+ {filteredEndTimeOptions.map((time) => (
2550
+ <SelectItem key={time} value={time}>
2551
+ {time}
2552
+ </SelectItem>
2553
+ ))}
2554
+ </SelectContent>
2555
+ </Select>
2556
+ )}
2557
+ />
2558
+ <FieldDescription>
2559
+ O termino mostra apenas horarios iguais ou depois do
2560
+ inicio.
2561
+ </FieldDescription>
2562
+ <FieldError>
2563
+ {form.formState.errors.horarioFim?.message}
2564
+ </FieldError>
2565
+ </Field>
2566
+ </div>
2567
+ </div>
2568
+ </div>
2569
+
2570
+ <div className="rounded-lg border border-border/70 p-4">
2571
+ <div className="space-y-4">
2572
+ <div className="space-y-1">
2573
+ <h3 className="text-sm font-semibold">
2574
+ {t('form.recurrence.sectionTitle')}
2575
+ </h3>
2576
+ <p className="text-sm text-muted-foreground">
2577
+ {t('form.recurrence.sectionDescription')}
2578
+ </p>
2579
+ </div>
2580
+
2581
+ <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
2582
+ <Field>
2583
+ <FieldLabel>{t('form.recurrence.label')}</FieldLabel>
2584
+ <Select
2585
+ value={watchedFormValues.sessionRecurrenceMode}
2586
+ onValueChange={(value) =>
2587
+ handleRecurrenceModeChange(
2588
+ value as SessionRecurrenceMode
2589
+ )
2590
+ }
2591
+ >
2592
+ <SelectTrigger>
2593
+ <SelectValue />
2594
+ </SelectTrigger>
2595
+ <SelectContent>
2596
+ <SelectItem value="none">
2597
+ {t('form.recurrence.options.none')}
1009
2598
  </SelectItem>
1010
- ))}
1011
- </SelectContent>
1012
- </Select>
1013
- )}
1014
- />
1015
- <FieldError>{form.formState.errors.curso?.message}</FieldError>
1016
- </Field>
1017
- <div className="grid grid-cols-2 gap-4">
2599
+ <SelectItem value="daily">
2600
+ {t('form.recurrence.options.daily')}
2601
+ </SelectItem>
2602
+ <SelectItem value="weekly">
2603
+ {t('form.recurrence.options.weekly')}
2604
+ </SelectItem>
2605
+ <SelectItem value="monthly">
2606
+ {t('form.recurrence.options.monthly')}
2607
+ </SelectItem>
2608
+ <SelectItem value="yearly">
2609
+ {t('form.recurrence.options.yearly')}
2610
+ </SelectItem>
2611
+ <SelectItem value="weekdays">
2612
+ {t('form.recurrence.options.weekdays')}
2613
+ </SelectItem>
2614
+ <SelectItem value="custom">
2615
+ {t('form.recurrence.options.custom')}
2616
+ </SelectItem>
2617
+ </SelectContent>
2618
+ </Select>
2619
+ <FieldDescription>{recurrenceSummaryText}</FieldDescription>
2620
+ <FieldError>
2621
+ {form.formState.errors.sessionRecurrenceMode?.message}
2622
+ </FieldError>
2623
+ </Field>
2624
+
2625
+ <Field>
2626
+ <FieldLabel>{t('form.recurrence.until')}</FieldLabel>
2627
+ <Input
2628
+ type="date"
2629
+ value={watchedFormValues.sessionRecurrenceUntil ?? ''}
2630
+ min={watchedFormValues.dataInicio || undefined}
2631
+ onChange={(event) =>
2632
+ form.setValue(
2633
+ 'sessionRecurrenceUntil',
2634
+ event.target.value,
2635
+ {
2636
+ shouldDirty: true,
2637
+ shouldTouch: true,
2638
+ shouldValidate: true,
2639
+ }
2640
+ )
2641
+ }
2642
+ />
2643
+ <FieldError>
2644
+ {form.formState.errors.sessionRecurrenceUntil?.message}
2645
+ </FieldError>
2646
+ </Field>
2647
+ </div>
2648
+
2649
+ <div className="grid gap-4 md:grid-cols-[minmax(0,0.55fr)_minmax(0,1fr)]">
2650
+ <Field>
2651
+ <FieldLabel>
2652
+ {t('form.recurrence.titleMode.label')}
2653
+ </FieldLabel>
2654
+ <Controller
2655
+ name="sessionTitleMode"
2656
+ control={form.control}
2657
+ render={({ field }) => (
2658
+ <Select
2659
+ value={field.value}
2660
+ onValueChange={(value) => {
2661
+ field.onChange(value);
2662
+ if (value === 'default-course-code') {
2663
+ form.setValue(
2664
+ 'sessionTitle',
2665
+ defaultSessionTitle,
2666
+ {
2667
+ shouldDirty: true,
2668
+ shouldTouch: false,
2669
+ shouldValidate: false,
2670
+ }
2671
+ );
2672
+ }
2673
+ }}
2674
+ >
2675
+ <SelectTrigger>
2676
+ <SelectValue />
2677
+ </SelectTrigger>
2678
+ <SelectContent>
2679
+ <SelectItem value="default-course-code">
2680
+ {t('form.recurrence.titleMode.default')}
2681
+ </SelectItem>
2682
+ <SelectItem value="custom">
2683
+ {t('form.recurrence.titleMode.custom')}
2684
+ </SelectItem>
2685
+ </SelectContent>
2686
+ </Select>
2687
+ )}
2688
+ />
2689
+ </Field>
2690
+
2691
+ <Field>
2692
+ <FieldLabel htmlFor="sessionTitle">
2693
+ {t('form.fields.sessionTitle.label')}
2694
+ </FieldLabel>
2695
+ <Input
2696
+ id="sessionTitle"
2697
+ value={
2698
+ watchedFormValues.sessionTitleMode ===
2699
+ 'default-course-code'
2700
+ ? defaultSessionTitle
2701
+ : (watchedFormValues.sessionTitle ?? '')
2702
+ }
2703
+ placeholder={t('form.fields.sessionTitle.placeholder')}
2704
+ disabled={
2705
+ watchedFormValues.sessionTitleMode ===
2706
+ 'default-course-code'
2707
+ }
2708
+ onChange={(event) =>
2709
+ form.setValue('sessionTitle', event.target.value, {
2710
+ shouldDirty: true,
2711
+ shouldTouch: true,
2712
+ shouldValidate: false,
2713
+ })
2714
+ }
2715
+ />
2716
+ <FieldDescription>
2717
+ {watchedFormValues.sessionTitleMode ===
2718
+ 'default-course-code'
2719
+ ? defaultSessionTitle ||
2720
+ t('form.fields.sessionTitle.placeholder')
2721
+ : recurrenceSummaryText}
2722
+ </FieldDescription>
2723
+ <FieldError>
2724
+ {form.formState.errors.sessionTitle?.message}
2725
+ </FieldError>
2726
+ </Field>
2727
+ </div>
2728
+ </div>
2729
+ </div>
2730
+
2731
+ <div className="grid gap-4 md:grid-cols-3">
1018
2732
  <Field>
1019
2733
  <FieldLabel>
1020
2734
  {t('form.fields.type.label')}{' '}
@@ -1044,6 +2758,7 @@ export default function TurmasPage() {
1044
2758
  />
1045
2759
  <FieldError>{form.formState.errors.tipo?.message}</FieldError>
1046
2760
  </Field>
2761
+
1047
2762
  <Field>
1048
2763
  <FieldLabel>
1049
2764
  {t('form.fields.status.label')}{' '}
@@ -1076,87 +2791,344 @@ export default function TurmasPage() {
1076
2791
  />
1077
2792
  <FieldError>{form.formState.errors.status?.message}</FieldError>
1078
2793
  </Field>
2794
+
2795
+ <Field>
2796
+ <FieldLabel htmlFor="vagas">
2797
+ {t('form.fields.vacancies.label')}{' '}
2798
+ <span className="text-destructive">*</span>
2799
+ </FieldLabel>
2800
+ <Input
2801
+ id="vagas"
2802
+ type="number"
2803
+ min={1}
2804
+ {...form.register('vagas')}
2805
+ />
2806
+ <FieldError>{form.formState.errors.vagas?.message}</FieldError>
2807
+ </Field>
1079
2808
  </div>
2809
+
1080
2810
  <Field>
1081
- <FieldLabel htmlFor="professor">
2811
+ <FieldLabel>
1082
2812
  {t('form.fields.professor.label')}{' '}
1083
2813
  <span className="text-destructive">*</span>
1084
2814
  </FieldLabel>
1085
- <Input
1086
- id="professor"
1087
- placeholder={t('form.fields.professor.placeholder')}
1088
- {...form.register('professor')}
2815
+ <Controller
2816
+ name="professor"
2817
+ control={form.control}
2818
+ render={({ field }) => (
2819
+ <div className="flex items-end gap-2">
2820
+ <div className="flex-1">
2821
+ <Popover
2822
+ open={professorOpen}
2823
+ onOpenChange={setProfessorOpen}
2824
+ >
2825
+ <PopoverTrigger asChild>
2826
+ <Button
2827
+ type="button"
2828
+ variant="outline"
2829
+ role="combobox"
2830
+ className="w-full justify-between"
2831
+ >
2832
+ <span className="truncate text-left">
2833
+ {field.value ||
2834
+ t('form.fields.professor.placeholder')}
2835
+ </span>
2836
+ {loadingProfessores ? (
2837
+ <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-60" />
2838
+ ) : (
2839
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
2840
+ )}
2841
+ </Button>
2842
+ </PopoverTrigger>
2843
+ <PopoverContent className="p-0" align="start">
2844
+ <Command shouldFilter={false}>
2845
+ <CommandInput
2846
+ placeholder={t(
2847
+ 'form.fields.professor.placeholder'
2848
+ )}
2849
+ value={professorSearch}
2850
+ onValueChange={setProfessorSearch}
2851
+ />
2852
+ <CommandList>
2853
+ <CommandEmpty>
2854
+ <div className="flex flex-col items-center gap-3 py-4 px-2">
2855
+ <p className="text-sm text-muted-foreground">
2856
+ Nenhum professor encontrado.
2857
+ </p>
2858
+ <Button
2859
+ type="button"
2860
+ variant="outline"
2861
+ size="sm"
2862
+ className="w-full"
2863
+ onClick={() => {
2864
+ setProfessorOpen(false);
2865
+ setCreateProfessorDialogOpen(true);
2866
+ }}
2867
+ >
2868
+ <Plus className="mr-2 h-4 w-4" />
2869
+ Cadastrar novo professor
2870
+ </Button>
2871
+ </div>
2872
+ </CommandEmpty>
2873
+ <CommandGroup>
2874
+ {professorOptions.map((professor) => (
2875
+ <CommandItem
2876
+ key={professor.id}
2877
+ value={`${professor.name}-${professor.id}`}
2878
+ onSelect={() => {
2879
+ form.setValue(
2880
+ 'instructorId',
2881
+ professor.id,
2882
+ {
2883
+ shouldDirty: true,
2884
+ shouldTouch: true,
2885
+ shouldValidate: true,
2886
+ }
2887
+ );
2888
+ field.onChange(professor.name);
2889
+ setProfessorOpen(false);
2890
+ setProfessorSearch('');
2891
+ }}
2892
+ >
2893
+ {professor.name}
2894
+ </CommandItem>
2895
+ ))}
2896
+ </CommandGroup>
2897
+ </CommandList>
2898
+ </Command>
2899
+ </PopoverContent>
2900
+ </Popover>
2901
+ </div>
2902
+
2903
+ <Button
2904
+ type="button"
2905
+ variant="outline"
2906
+ size="icon"
2907
+ className="shrink-0"
2908
+ onClick={() => setCreateProfessorDialogOpen(true)}
2909
+ aria-label="Cadastrar novo professor"
2910
+ >
2911
+ <Plus className="h-4 w-4" />
2912
+ </Button>
2913
+ </div>
2914
+ )}
1089
2915
  />
1090
2916
  <FieldError>
1091
2917
  {form.formState.errors.professor?.message}
1092
2918
  </FieldError>
1093
2919
  </Field>
1094
- <Field>
1095
- <FieldLabel htmlFor="vagas">
1096
- {t('form.fields.vacancies.label')}{' '}
1097
- <span className="text-destructive">*</span>
1098
- </FieldLabel>
1099
- <Input
1100
- id="vagas"
1101
- type="number"
1102
- min={1}
1103
- {...form.register('vagas')}
1104
- />
1105
- <FieldError>{form.formState.errors.vagas?.message}</FieldError>
1106
- </Field>
1107
- <div className="grid grid-cols-2 gap-4">
2920
+
2921
+ <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">
2922
+ <Button type="submit" disabled={saving} className="gap-2">
2923
+ {saving && <Loader2 className="size-4 animate-spin" />}
2924
+ {editingTurma
2925
+ ? t('form.actions.save')
2926
+ : t('form.actions.create')}
2927
+ </Button>
2928
+ </SheetFooter>
2929
+ </form>
2930
+ </SheetContent>
2931
+ </Sheet>
2932
+
2933
+ <CourseFormSheet
2934
+ key="inline-course-create"
2935
+ open={courseSheetOpen}
2936
+ onOpenChange={setCourseSheetOpen}
2937
+ editing={false}
2938
+ saving={savingCourse}
2939
+ form={courseForm}
2940
+ onSubmit={onSubmitCourse}
2941
+ categories={categoryOptions}
2942
+ onCreateCategory={() => router.push('/category?new=1')}
2943
+ t={courseSheetT}
2944
+ />
2945
+
2946
+ <CreateLmsPersonSheet
2947
+ open={createProfessorDialogOpen}
2948
+ onOpenChange={setCreateProfessorDialogOpen}
2949
+ onCreated={handleProfessorCreated}
2950
+ title="Cadastrar novo professor"
2951
+ description="Crie um novo professor para seleciona-lo nesta turma."
2952
+ submitLabel="Cadastrar"
2953
+ successMessage="Professor cadastrado com sucesso."
2954
+ errorMessage="Nao foi possivel cadastrar o professor."
2955
+ defaultQualificationSlugs={['class-sessions']}
2956
+ />
2957
+
2958
+ <Dialog
2959
+ open={customRecurrenceDialogOpen}
2960
+ onOpenChange={(open) => {
2961
+ if (!open) {
2962
+ if (customRecurrenceConfirmedRef.current) {
2963
+ customRecurrenceConfirmedRef.current = false;
2964
+ setCustomRecurrenceDialogOpen(false);
2965
+ return;
2966
+ }
2967
+
2968
+ handleCustomRecurrenceCancel();
2969
+ return;
2970
+ }
2971
+
2972
+ setCustomRecurrenceDialogOpen(true);
2973
+ }}
2974
+ >
2975
+ <DialogContent className="sm:max-w-xl">
2976
+ <DialogHeader>
2977
+ <DialogTitle>{t('form.recurrence.customDialog.title')}</DialogTitle>
2978
+ <DialogDescription>
2979
+ {t('form.recurrence.customDialog.description')}
2980
+ </DialogDescription>
2981
+ </DialogHeader>
2982
+
2983
+ <div className="grid gap-5 py-2">
2984
+ <div className="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)] sm:items-end">
1108
2985
  <Field>
1109
- <FieldLabel htmlFor="dataInicio">
1110
- {t('form.fields.startDate.label')}{' '}
1111
- <span className="text-destructive">*</span>
2986
+ <FieldLabel>
2987
+ {t('form.recurrence.customDialog.repeatEvery')}
1112
2988
  </FieldLabel>
1113
2989
  <Input
1114
- id="dataInicio"
1115
- type="date"
1116
- {...form.register('dataInicio')}
2990
+ type="number"
2991
+ min={1}
2992
+ value={watchedFormValues.sessionRecurrenceInterval ?? 1}
2993
+ onChange={(event) =>
2994
+ form.setValue(
2995
+ 'sessionRecurrenceInterval',
2996
+ Number(event.target.value) || 1,
2997
+ {
2998
+ shouldDirty: true,
2999
+ shouldTouch: true,
3000
+ shouldValidate: true,
3001
+ }
3002
+ )
3003
+ }
1117
3004
  />
1118
3005
  <FieldError>
1119
- {form.formState.errors.dataInicio?.message}
3006
+ {form.formState.errors.sessionRecurrenceInterval?.message}
1120
3007
  </FieldError>
1121
3008
  </Field>
3009
+
1122
3010
  <Field>
1123
- <FieldLabel htmlFor="dataFim">
1124
- {t('form.fields.endDate.label')}{' '}
1125
- <span className="text-destructive">*</span>
3011
+ <FieldLabel>&nbsp;</FieldLabel>
3012
+ <Controller
3013
+ name="sessionRecurrenceCustomFrequency"
3014
+ control={form.control}
3015
+ render={({ field }) => (
3016
+ <Select
3017
+ value={field.value}
3018
+ onValueChange={(value) => {
3019
+ field.onChange(value);
3020
+ if (
3021
+ value === 'weekly' &&
3022
+ (watchedFormValues.sessionRecurrenceDaysOfWeek ?? [])
3023
+ .length === 0 &&
3024
+ watchedFormValues.dataInicio
3025
+ ) {
3026
+ form.setValue(
3027
+ 'sessionRecurrenceDaysOfWeek',
3028
+ [getDayCodeFromDate(watchedFormValues.dataInicio)],
3029
+ {
3030
+ shouldDirty: true,
3031
+ shouldTouch: false,
3032
+ shouldValidate: true,
3033
+ }
3034
+ );
3035
+ }
3036
+ }}
3037
+ >
3038
+ <SelectTrigger>
3039
+ <SelectValue />
3040
+ </SelectTrigger>
3041
+ <SelectContent>
3042
+ <SelectItem value="daily">
3043
+ {t('form.recurrence.customDialog.frequency.daily')}
3044
+ </SelectItem>
3045
+ <SelectItem value="weekly">
3046
+ {t('form.recurrence.customDialog.frequency.weekly')}
3047
+ </SelectItem>
3048
+ <SelectItem value="monthly">
3049
+ {t('form.recurrence.customDialog.frequency.monthly')}
3050
+ </SelectItem>
3051
+ <SelectItem value="yearly">
3052
+ {t('form.recurrence.customDialog.frequency.yearly')}
3053
+ </SelectItem>
3054
+ </SelectContent>
3055
+ </Select>
3056
+ )}
3057
+ />
3058
+ </Field>
3059
+ </div>
3060
+
3061
+ {customRecurrenceNeedsWeekdays && (
3062
+ <Field>
3063
+ <FieldLabel>
3064
+ {t('form.recurrence.customDialog.repeatOn')}
1126
3065
  </FieldLabel>
1127
- <Input id="dataFim" type="date" {...form.register('dataFim')} />
3066
+ <div className="flex flex-wrap gap-2">
3067
+ {recurrenceDayOptions.map((day) => {
3068
+ const active = (
3069
+ watchedFormValues.sessionRecurrenceDaysOfWeek ?? []
3070
+ ).includes(day.value);
3071
+
3072
+ return (
3073
+ <Button
3074
+ key={day.value}
3075
+ type="button"
3076
+ variant={active ? 'default' : 'outline'}
3077
+ size="icon"
3078
+ className="rounded-full"
3079
+ onClick={() => toggleCustomRecurrenceDay(day.value)}
3080
+ >
3081
+ {day.label}
3082
+ </Button>
3083
+ );
3084
+ })}
3085
+ </div>
1128
3086
  <FieldError>
1129
- {form.formState.errors.dataFim?.message}
3087
+ {form.formState.errors.sessionRecurrenceDaysOfWeek?.message}
1130
3088
  </FieldError>
1131
3089
  </Field>
1132
- </div>
3090
+ )}
3091
+
1133
3092
  <Field>
1134
- <FieldLabel htmlFor="horario">
1135
- {t('form.fields.schedule.label')}{' '}
1136
- <span className="text-destructive">*</span>
3093
+ <FieldLabel>
3094
+ {t('form.recurrence.customDialog.endDate')}
1137
3095
  </FieldLabel>
1138
3096
  <Input
1139
- id="horario"
1140
- placeholder={t('form.fields.schedule.placeholder')}
1141
- {...form.register('horario')}
3097
+ type="date"
3098
+ min={watchedFormValues.dataInicio || undefined}
3099
+ value={watchedFormValues.sessionRecurrenceUntil ?? ''}
3100
+ onChange={(event) =>
3101
+ form.setValue('sessionRecurrenceUntil', event.target.value, {
3102
+ shouldDirty: true,
3103
+ shouldTouch: true,
3104
+ shouldValidate: true,
3105
+ })
3106
+ }
1142
3107
  />
1143
- <FieldError>{form.formState.errors.horario?.message}</FieldError>
3108
+ <FieldError>
3109
+ {form.formState.errors.sessionRecurrenceUntil?.message}
3110
+ </FieldError>
1144
3111
  </Field>
1145
- <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">
1146
- <Button type="submit" disabled={saving} className="gap-2">
1147
- {saving && <Loader2 className="size-4 animate-spin" />}
1148
- {editingTurma
1149
- ? t('form.actions.save')
1150
- : t('form.actions.create')}
1151
- </Button>
1152
- </SheetFooter>
1153
- </form>
1154
- </SheetContent>
1155
- </Sheet>
3112
+ </div>
3113
+
3114
+ <DialogFooter className="gap-2">
3115
+ <Button
3116
+ type="button"
3117
+ variant="outline"
3118
+ onClick={handleCustomRecurrenceCancel}
3119
+ >
3120
+ {t('form.recurrence.customDialog.cancel')}
3121
+ </Button>
3122
+ <Button type="button" onClick={handleCustomRecurrenceConfirm}>
3123
+ {t('form.recurrence.customDialog.confirm')}
3124
+ </Button>
3125
+ </DialogFooter>
3126
+ </DialogContent>
3127
+ </Dialog>
1156
3128
 
1157
3129
  {/* Delete Dialog */}
1158
3130
  <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1159
- <DialogContent>
3131
+ <DialogContent className="mx-auto max-w-3xl">
1160
3132
  <DialogHeader>
1161
3133
  <DialogTitle className="flex items-center gap-2">
1162
3134
  <AlertTriangle className="size-5 text-destructive" />{' '}