@hed-hog/lms 0.0.304 → 0.0.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. package/README.md +413 -401
  2. package/dist/certificate/certificate.controller.d.ts +90 -0
  3. package/dist/certificate/certificate.controller.d.ts.map +1 -0
  4. package/dist/certificate/certificate.controller.js +121 -0
  5. package/dist/certificate/certificate.controller.js.map +1 -0
  6. package/dist/certificate/certificate.module.d.ts +3 -0
  7. package/dist/certificate/certificate.module.d.ts.map +1 -0
  8. package/dist/certificate/certificate.module.js +26 -0
  9. package/dist/certificate/certificate.module.js.map +1 -0
  10. package/dist/certificate/certificate.service.d.ts +115 -0
  11. package/dist/certificate/certificate.service.d.ts.map +1 -0
  12. package/dist/certificate/certificate.service.js +343 -0
  13. package/dist/certificate/certificate.service.js.map +1 -0
  14. package/dist/certificate/dto/create-certificate-template.dto.d.ts +8 -0
  15. package/dist/certificate/dto/create-certificate-template.dto.d.ts.map +1 -0
  16. package/dist/certificate/dto/create-certificate-template.dto.js +44 -0
  17. package/dist/certificate/dto/create-certificate-template.dto.js.map +1 -0
  18. package/dist/certificate/dto/update-certificate-template.dto.d.ts +6 -0
  19. package/dist/certificate/dto/update-certificate-template.dto.d.ts.map +1 -0
  20. package/dist/certificate/dto/update-certificate-template.dto.js +9 -0
  21. package/dist/certificate/dto/update-certificate-template.dto.js.map +1 -0
  22. package/dist/class-group/class-group.controller.d.ts +305 -0
  23. package/dist/class-group/class-group.controller.d.ts.map +1 -0
  24. package/dist/class-group/class-group.controller.js +257 -0
  25. package/dist/class-group/class-group.controller.js.map +1 -0
  26. package/dist/class-group/class-group.module.d.ts +3 -0
  27. package/dist/class-group/class-group.module.d.ts.map +1 -0
  28. package/dist/class-group/class-group.module.js +25 -0
  29. package/dist/class-group/class-group.module.js.map +1 -0
  30. package/dist/class-group/class-group.service.d.ts +354 -0
  31. package/dist/class-group/class-group.service.d.ts.map +1 -0
  32. package/dist/class-group/class-group.service.js +1356 -0
  33. package/dist/class-group/class-group.service.js.map +1 -0
  34. package/dist/class-group/dto/create-class-group.dto.d.ts +33 -0
  35. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -0
  36. package/dist/class-group/dto/create-class-group.dto.js +165 -0
  37. package/dist/class-group/dto/create-class-group.dto.js.map +1 -0
  38. package/dist/class-group/dto/create-session.dto.d.ts +22 -0
  39. package/dist/class-group/dto/create-session.dto.d.ts.map +1 -0
  40. package/dist/class-group/dto/create-session.dto.js +117 -0
  41. package/dist/class-group/dto/create-session.dto.js.map +1 -0
  42. package/dist/class-group/dto/enrollment.dto.d.ts +22 -0
  43. package/dist/class-group/dto/enrollment.dto.d.ts.map +1 -0
  44. package/dist/class-group/dto/enrollment.dto.js +89 -0
  45. package/dist/class-group/dto/enrollment.dto.js.map +1 -0
  46. package/dist/class-group/dto/update-class-group.dto.d.ts +6 -0
  47. package/dist/class-group/dto/update-class-group.dto.d.ts.map +1 -0
  48. package/dist/class-group/dto/update-class-group.dto.js +9 -0
  49. package/dist/class-group/dto/update-class-group.dto.js.map +1 -0
  50. package/dist/class-group/dto/update-session.dto.d.ts +7 -0
  51. package/dist/class-group/dto/update-session.dto.d.ts.map +1 -0
  52. package/dist/class-group/dto/update-session.dto.js +24 -0
  53. package/dist/class-group/dto/update-session.dto.js.map +1 -0
  54. package/dist/course/course-structure.controller.d.ts +127 -0
  55. package/dist/course/course-structure.controller.d.ts.map +1 -0
  56. package/dist/course/course-structure.controller.js +115 -0
  57. package/dist/course/course-structure.controller.js.map +1 -0
  58. package/dist/course/course-structure.service.d.ts +142 -0
  59. package/dist/course/course-structure.service.d.ts.map +1 -0
  60. package/dist/course/course-structure.service.js +445 -0
  61. package/dist/course/course-structure.service.js.map +1 -0
  62. package/dist/course/course.controller.d.ts +195 -0
  63. package/dist/course/course.controller.d.ts.map +1 -0
  64. package/dist/course/course.controller.js +104 -0
  65. package/dist/course/course.controller.js.map +1 -0
  66. package/dist/course/course.module.d.ts +3 -0
  67. package/dist/course/course.module.d.ts.map +1 -0
  68. package/dist/course/course.module.js +28 -0
  69. package/dist/course/course.module.js.map +1 -0
  70. package/dist/course/course.service.d.ts +215 -0
  71. package/dist/course/course.service.d.ts.map +1 -0
  72. package/dist/course/course.service.js +743 -0
  73. package/dist/course/course.service.js.map +1 -0
  74. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +24 -0
  75. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -0
  76. package/dist/course/dto/create-course-structure-lesson.dto.js +118 -0
  77. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -0
  78. package/dist/course/dto/create-course-structure-session.dto.d.ts +7 -0
  79. package/dist/course/dto/create-course-structure-session.dto.d.ts.map +1 -0
  80. package/dist/course/dto/create-course-structure-session.dto.js +40 -0
  81. package/dist/course/dto/create-course-structure-session.dto.js.map +1 -0
  82. package/dist/course/dto/create-course.dto.d.ts +26 -0
  83. package/dist/course/dto/create-course.dto.d.ts.map +1 -0
  84. package/dist/course/dto/create-course.dto.js +138 -0
  85. package/dist/course/dto/create-course.dto.js.map +1 -0
  86. package/dist/course/dto/update-course-structure-lesson.dto.d.ts +6 -0
  87. package/dist/course/dto/update-course-structure-lesson.dto.d.ts.map +1 -0
  88. package/dist/course/dto/update-course-structure-lesson.dto.js +9 -0
  89. package/dist/course/dto/update-course-structure-lesson.dto.js.map +1 -0
  90. package/dist/course/dto/update-course-structure-session.dto.d.ts +6 -0
  91. package/dist/course/dto/update-course-structure-session.dto.d.ts.map +1 -0
  92. package/dist/course/dto/update-course-structure-session.dto.js +9 -0
  93. package/dist/course/dto/update-course-structure-session.dto.js.map +1 -0
  94. package/dist/course/dto/update-course.dto.d.ts +6 -0
  95. package/dist/course/dto/update-course.dto.d.ts.map +1 -0
  96. package/dist/course/dto/update-course.dto.js +9 -0
  97. package/dist/course/dto/update-course.dto.js.map +1 -0
  98. package/dist/dashboard/dashboard.controller.d.ts +101 -0
  99. package/dist/dashboard/dashboard.controller.d.ts.map +1 -0
  100. package/dist/dashboard/dashboard.controller.js +40 -0
  101. package/dist/dashboard/dashboard.controller.js.map +1 -0
  102. package/dist/dashboard/dashboard.module.d.ts +3 -0
  103. package/dist/dashboard/dashboard.module.d.ts.map +1 -0
  104. package/dist/dashboard/dashboard.module.js +25 -0
  105. package/dist/dashboard/dashboard.module.js.map +1 -0
  106. package/dist/dashboard/dashboard.service.d.ts +130 -0
  107. package/dist/dashboard/dashboard.service.d.ts.map +1 -0
  108. package/dist/dashboard/dashboard.service.js +626 -0
  109. package/dist/dashboard/dashboard.service.js.map +1 -0
  110. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts +4 -0
  111. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts.map +1 -0
  112. package/dist/enterprise/dto/add-enterprise-class-group.dto.js +22 -0
  113. package/dist/enterprise/dto/add-enterprise-class-group.dto.js.map +1 -0
  114. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts +5 -0
  115. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts.map +1 -0
  116. package/dist/enterprise/dto/add-enterprise-course.dto.js +27 -0
  117. package/dist/enterprise/dto/add-enterprise-course.dto.js.map +1 -0
  118. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts +5 -0
  119. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  121. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  123. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  125. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  126. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  127. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  129. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  130. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  131. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  133. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  135. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  137. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  139. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  141. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  142. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  143. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  144. package/dist/enterprise/enterprise.controller.js +311 -0
  145. package/dist/enterprise/enterprise.controller.js.map +1 -0
  146. package/dist/enterprise/enterprise.module.d.ts +3 -0
  147. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.module.js +25 -0
  149. package/dist/enterprise/enterprise.module.js.map +1 -0
  150. package/dist/enterprise/enterprise.service.d.ts +282 -0
  151. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.service.js +627 -0
  153. package/dist/enterprise/enterprise.service.js.map +1 -0
  154. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  155. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  156. package/dist/evaluation/evaluation.controller.js +76 -0
  157. package/dist/evaluation/evaluation.controller.js.map +1 -0
  158. package/dist/evaluation/evaluation.module.d.ts +3 -0
  159. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.module.js +25 -0
  161. package/dist/evaluation/evaluation.module.js.map +1 -0
  162. package/dist/evaluation/evaluation.service.d.ts +67 -0
  163. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.service.js +378 -0
  165. package/dist/evaluation/evaluation.service.js.map +1 -0
  166. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  167. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  168. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  169. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  170. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  171. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam.dto.js +63 -0
  173. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  174. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  175. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  177. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  178. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  179. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  181. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  182. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  183. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  185. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  186. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  187. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  191. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  193. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam.dto.js +9 -0
  197. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  198. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  199. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  200. package/dist/exam/exam-attempt.controller.js +84 -0
  201. package/dist/exam/exam-attempt.controller.js.map +1 -0
  202. package/dist/exam/exam-attempt.service.d.ts +302 -0
  203. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.service.js +776 -0
  205. package/dist/exam/exam-attempt.service.js.map +1 -0
  206. package/dist/exam/exam.controller.d.ts +162 -0
  207. package/dist/exam/exam.controller.d.ts.map +1 -0
  208. package/dist/exam/exam.controller.js +158 -0
  209. package/dist/exam/exam.controller.js.map +1 -0
  210. package/dist/exam/exam.module.d.ts +3 -0
  211. package/dist/exam/exam.module.d.ts.map +1 -0
  212. package/dist/exam/exam.module.js +27 -0
  213. package/dist/exam/exam.module.js.map +1 -0
  214. package/dist/exam/exam.service.d.ts +179 -0
  215. package/dist/exam/exam.service.d.ts.map +1 -0
  216. package/dist/exam/exam.service.js +597 -0
  217. package/dist/exam/exam.service.js.map +1 -0
  218. package/dist/index.d.ts +28 -0
  219. package/dist/index.d.ts.map +1 -1
  220. package/dist/index.js +28 -0
  221. package/dist/index.js.map +1 -1
  222. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  223. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  224. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  225. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  226. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  227. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  229. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  230. package/dist/instructor/instructor.controller.d.ts +52 -0
  231. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  232. package/dist/instructor/instructor.controller.js +98 -0
  233. package/dist/instructor/instructor.controller.js.map +1 -0
  234. package/dist/instructor/instructor.module.d.ts +3 -0
  235. package/dist/instructor/instructor.module.d.ts.map +1 -0
  236. package/dist/instructor/instructor.module.js +25 -0
  237. package/dist/instructor/instructor.module.js.map +1 -0
  238. package/dist/instructor/instructor.service.d.ts +79 -0
  239. package/dist/instructor/instructor.service.d.ts.map +1 -0
  240. package/dist/instructor/instructor.service.js +528 -0
  241. package/dist/instructor/instructor.service.js.map +1 -0
  242. package/dist/lms.module.d.ts.map +1 -1
  243. package/dist/lms.module.js +36 -4
  244. package/dist/lms.module.js.map +1 -1
  245. package/dist/reports/reports.controller.d.ts +69 -0
  246. package/dist/reports/reports.controller.d.ts.map +1 -0
  247. package/dist/reports/reports.controller.js +40 -0
  248. package/dist/reports/reports.controller.js.map +1 -0
  249. package/dist/reports/reports.module.d.ts +3 -0
  250. package/dist/reports/reports.module.d.ts.map +1 -0
  251. package/dist/reports/reports.module.js +25 -0
  252. package/dist/reports/reports.module.js.map +1 -0
  253. package/dist/reports/reports.service.d.ts +80 -0
  254. package/dist/reports/reports.service.d.ts.map +1 -0
  255. package/dist/reports/reports.service.js +366 -0
  256. package/dist/reports/reports.service.js.map +1 -0
  257. package/dist/training/dto/create-training.dto.d.ts +19 -0
  258. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  259. package/dist/training/dto/create-training.dto.js +98 -0
  260. package/dist/training/dto/create-training.dto.js.map +1 -0
  261. package/dist/training/dto/update-training.dto.d.ts +6 -0
  262. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/update-training.dto.js +9 -0
  264. package/dist/training/dto/update-training.dto.js.map +1 -0
  265. package/dist/training/training.controller.d.ts +195 -0
  266. package/dist/training/training.controller.d.ts.map +1 -0
  267. package/dist/training/training.controller.js +104 -0
  268. package/dist/training/training.controller.js.map +1 -0
  269. package/dist/training/training.module.d.ts +3 -0
  270. package/dist/training/training.module.d.ts.map +1 -0
  271. package/dist/training/training.module.js +25 -0
  272. package/dist/training/training.module.js.map +1 -0
  273. package/dist/training/training.service.d.ts +213 -0
  274. package/dist/training/training.service.d.ts.map +1 -0
  275. package/dist/training/training.service.js +497 -0
  276. package/dist/training/training.service.js.map +1 -0
  277. package/hedhog/data/dashboard.yaml +6 -0
  278. package/hedhog/data/dashboard_component.yaml +153 -0
  279. package/hedhog/data/dashboard_component_role.yaml +97 -0
  280. package/hedhog/data/dashboard_item.yaml +167 -0
  281. package/hedhog/data/dashboard_role.yaml +6 -0
  282. package/hedhog/data/instructor_qualification.yaml +16 -0
  283. package/hedhog/data/menu.yaml +129 -19
  284. package/hedhog/data/role.yaml +25 -1
  285. package/hedhog/data/route.yaml +867 -0
  286. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  287. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  288. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  289. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  290. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  291. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  292. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  293. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  294. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  295. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  296. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  297. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  298. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  299. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  300. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  301. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  302. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  303. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  304. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  305. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  306. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  307. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  308. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  309. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  310. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  311. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  312. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  313. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  322. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  323. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  324. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  325. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  326. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  327. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  328. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  329. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +58 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +224 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  348. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  349. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  350. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  351. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  352. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  353. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  354. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  355. package/hedhog/frontend/app/page.tsx.ejs +3 -1220
  356. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  357. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  358. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  359. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  360. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  361. package/hedhog/frontend/messages/en.json +1606 -111
  362. package/hedhog/frontend/messages/pt.json +1636 -134
  363. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  364. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  365. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  366. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  367. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  368. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  369. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  370. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  371. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  372. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  373. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  374. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  375. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  376. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  377. package/hedhog/table/course.yaml +19 -1
  378. package/hedhog/table/course_class_group.yaml +8 -0
  379. package/hedhog/table/course_class_session.yaml +33 -0
  380. package/hedhog/table/course_instructor.yaml +27 -0
  381. package/hedhog/table/enterprise.yaml +29 -0
  382. package/hedhog/table/enterprise_class_group.yaml +20 -0
  383. package/hedhog/table/enterprise_course.yaml +23 -0
  384. package/hedhog/table/enterprise_student.yaml +24 -0
  385. package/hedhog/table/enterprise_user.yaml +35 -0
  386. package/hedhog/table/instructor_qualification.yaml +26 -0
  387. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  388. package/hedhog/table/question.yaml +6 -0
  389. package/package.json +6 -6
  390. package/src/certificate/certificate.controller.ts +83 -0
  391. package/src/certificate/certificate.module.ts +13 -0
  392. package/src/certificate/certificate.service.ts +413 -0
  393. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  394. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  395. package/src/class-group/class-group.controller.ts +189 -0
  396. package/src/class-group/class-group.module.ts +12 -0
  397. package/src/class-group/class-group.service.ts +1802 -0
  398. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  399. package/src/class-group/dto/create-session.dto.ts +102 -0
  400. package/src/class-group/dto/enrollment.dto.ts +70 -0
  401. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  402. package/src/class-group/dto/update-session.dto.ts +9 -0
  403. package/src/course/course-structure.controller.ts +85 -0
  404. package/src/course/course-structure.service.ts +525 -0
  405. package/src/course/course.controller.ts +69 -0
  406. package/src/course/course.module.ts +15 -0
  407. package/src/course/course.service.ts +920 -0
  408. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  409. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  410. package/src/course/dto/create-course.dto.ts +111 -0
  411. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  412. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  413. package/src/course/dto/update-course.dto.ts +4 -0
  414. package/src/dashboard/dashboard.controller.ts +14 -0
  415. package/src/dashboard/dashboard.module.ts +12 -0
  416. package/src/dashboard/dashboard.service.ts +726 -0
  417. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  418. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  419. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  420. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  421. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  422. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  423. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  424. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  425. package/src/enterprise/enterprise.controller.ts +233 -0
  426. package/src/enterprise/enterprise.module.ts +12 -0
  427. package/src/enterprise/enterprise.service.ts +712 -0
  428. package/src/evaluation/evaluation.controller.ts +44 -0
  429. package/src/evaluation/evaluation.module.ts +12 -0
  430. package/src/evaluation/evaluation.service.ts +394 -0
  431. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  432. package/src/exam/dto/create-exam.dto.ts +41 -0
  433. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  434. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  435. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  436. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  437. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  438. package/src/exam/dto/update-exam.dto.ts +4 -0
  439. package/src/exam/exam-attempt.controller.ts +65 -0
  440. package/src/exam/exam-attempt.service.ts +1008 -0
  441. package/src/exam/exam.controller.ts +102 -0
  442. package/src/exam/exam.module.ts +14 -0
  443. package/src/exam/exam.service.ts +784 -0
  444. package/src/index.ts +29 -0
  445. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  446. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  447. package/src/instructor/instructor.controller.ts +73 -0
  448. package/src/instructor/instructor.module.ts +12 -0
  449. package/src/instructor/instructor.service.ts +646 -0
  450. package/src/lms.module.ts +36 -4
  451. package/src/reports/reports.controller.ts +14 -0
  452. package/src/reports/reports.module.ts +12 -0
  453. package/src/reports/reports.service.ts +485 -0
  454. package/src/training/dto/create-training.dto.ts +81 -0
  455. package/src/training/dto/update-training.dto.ts +4 -0
  456. package/src/training/training.controller.ts +68 -0
  457. package/src/training/training.module.ts +12 -0
  458. package/src/training/training.service.ts +574 -0
@@ -1,11 +1,21 @@
1
1
  'use client';
2
2
 
3
- import { EmptyState, Page, PageHeader } from '@/components/entity-list';
4
- import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
3
+ import { LmsClassCalendar } from '@/app/(app)/(libraries)/lms/_components/lms-class-calendar';
4
+ import { CopyButton } from '@/components/copy-button';
5
+ import { Page, PageHeader } from '@/components/entity-list';
6
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
5
7
  import { Badge } from '@/components/ui/badge';
6
8
  import { Button } from '@/components/ui/button';
7
9
  import { Card, CardContent } from '@/components/ui/card';
8
10
  import { Checkbox } from '@/components/ui/checkbox';
11
+ import {
12
+ Command,
13
+ CommandEmpty,
14
+ CommandGroup,
15
+ CommandInput,
16
+ CommandItem,
17
+ CommandList,
18
+ } from '@/components/ui/command';
9
19
  import {
10
20
  Dialog,
11
21
  DialogContent,
@@ -22,7 +32,21 @@ import {
22
32
  DropdownMenuTrigger,
23
33
  } from '@/components/ui/dropdown-menu';
24
34
  import { Field, FieldError, FieldLabel } from '@/components/ui/field';
35
+ import {
36
+ Form,
37
+ FormControl,
38
+ FormField,
39
+ FormItem,
40
+ FormLabel,
41
+ FormMessage,
42
+ } from '@/components/ui/form';
25
43
  import { Input } from '@/components/ui/input';
44
+ import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
45
+ import {
46
+ Popover,
47
+ PopoverContent,
48
+ PopoverTrigger,
49
+ } from '@/components/ui/popover';
26
50
  import {
27
51
  Select,
28
52
  SelectContent,
@@ -40,9 +64,31 @@ import {
40
64
  } from '@/components/ui/sheet';
41
65
  import { Skeleton } from '@/components/ui/skeleton';
42
66
  import { Switch } from '@/components/ui/switch';
67
+ import {
68
+ Table,
69
+ TableBody,
70
+ TableCell,
71
+ TableHead,
72
+ TableHeader,
73
+ TableRow,
74
+ } from '@/components/ui/table';
43
75
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
76
+ import { cn } from '@/lib/utils';
77
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
44
78
  import { zodResolver } from '@hookform/resolvers/zod';
45
- import { addDays, format, getDay, setHours, setMinutes } from 'date-fns';
79
+ import {
80
+ addMonths,
81
+ eachDayOfInterval,
82
+ endOfMonth,
83
+ endOfWeek,
84
+ format,
85
+ isSameDay,
86
+ isSameMonth,
87
+ setHours,
88
+ setMinutes,
89
+ startOfMonth,
90
+ startOfWeek,
91
+ } from 'date-fns';
46
92
  import { enUS, ptBR } from 'date-fns/locale';
47
93
  import { motion } from 'framer-motion';
48
94
  import {
@@ -51,13 +97,16 @@ import {
51
97
  Calendar as CalendarIcon,
52
98
  Check,
53
99
  CheckCircle2,
100
+ ChevronLeft,
101
+ ChevronRight,
102
+ ChevronsUpDown,
54
103
  Clock,
55
104
  Eye,
56
105
  Loader2,
57
- Mail,
58
106
  MapPin,
59
107
  Monitor,
60
108
  MoreHorizontal,
109
+ Pencil,
61
110
  Plus,
62
111
  Save,
63
112
  Search,
@@ -67,175 +116,464 @@ import {
67
116
  Video,
68
117
  } from 'lucide-react';
69
118
  import { useLocale, useTranslations } from 'next-intl';
70
- import Link from 'next/link';
71
119
  import { useParams, useRouter } from 'next/navigation';
72
- import { useCallback, useEffect, useMemo, useState } from 'react';
73
- import { Calendar, dateFnsLocalizer, View } from 'react-big-calendar';
74
- import 'react-big-calendar/lib/css/react-big-calendar.css';
120
+ import {
121
+ useCallback,
122
+ useEffect,
123
+ useMemo,
124
+ useState,
125
+ type ReactNode,
126
+ } from 'react';
75
127
  import { Controller, useForm } from 'react-hook-form';
76
128
  import { toast } from 'sonner';
77
129
  import { z } from 'zod';
130
+ import { ClassFormSheet } from '../../_components/class-form-sheet';
131
+ import { CreateLmsPersonSheet } from '../../_components/create-lms-person-sheet';
132
+ import { CreateLmsStudentPersonSheet } from '../../_components/create-lms-student-person-sheet';
78
133
 
79
134
  // ── Types ─────────────────────────────────────────────────────────────────────
80
135
 
81
136
  interface Aluno {
82
137
  id: number;
138
+ enrollmentId: number;
83
139
  nome: string;
84
140
  email: string;
85
141
  telefone: string;
86
- avatar?: string;
87
142
  matriculadoEm: string;
88
143
  progresso: number;
89
- presenca: number;
144
+ status: string;
90
145
  }
91
146
 
92
147
  interface Aula {
93
148
  id: number;
94
149
  titulo: string;
95
- data: Date;
150
+ data: string; // ISO date string from API
96
151
  horaInicio: string;
97
152
  horaFim: string;
98
153
  local: string;
154
+ meetingUrl: string;
99
155
  tipo: 'presencial' | 'online';
156
+ status: string;
157
+ instructorId?: number | null;
158
+ instructorName?: string;
159
+ recurrence?: {
160
+ frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
161
+ interval: number;
162
+ until: string;
163
+ daysOfWeek?: Array<'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'>;
164
+ } | null;
165
+ recurrenceId?: string | null;
166
+ occurrenceIndex?: number | null;
167
+ isException?: boolean;
168
+ isRecurring?: boolean;
169
+ cor?: string | null;
170
+ color?: string | null;
171
+ }
172
+
173
+ type SessionRecurrenceFrequency =
174
+ | 'none'
175
+ | 'daily'
176
+ | 'weekly'
177
+ | 'monthly'
178
+ | 'yearly';
179
+
180
+ type SessionRecurrenceDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
181
+
182
+ interface InstructorOption {
183
+ id: number;
184
+ name: string;
185
+ personId?: number;
186
+ qualificationSlugs?: string[];
187
+ }
188
+
189
+ type InstructorApiRow = {
190
+ id?: number | string;
191
+ instructor_id?: number | string;
192
+ value?: number | string;
193
+ name?: string;
194
+ nome?: string;
195
+ full_name?: string;
196
+ label?: string;
197
+ personId?: number | string;
198
+ person_id?: number | string;
199
+ qualificationSlugs?: string[];
200
+ };
201
+
202
+ interface Person {
203
+ id: number;
204
+ nome: string;
205
+ email: string;
206
+ isInstructor?: boolean;
207
+ canTeachCourses?: boolean;
208
+ isStudentByEnrollment?: boolean;
100
209
  }
101
210
 
102
211
  interface PresencaItem {
103
212
  alunoId: number;
213
+ selecionado: boolean;
104
214
  presente: boolean;
105
215
  }
106
216
 
107
- // ── Calendar Localizer ────────────────────────────────────────────────────────
217
+ interface OpenAulaSheetOptions {
218
+ initialTab?: 'aulas' | 'chamada';
219
+ prefill?: Partial<AulaForm>;
220
+ attendance?: PresencaItem[];
221
+ }
108
222
 
109
- const locales = { 'pt-BR': ptBR, 'en-US': enUS };
110
- const localizer = dateFnsLocalizer({
111
- format,
112
- parse: (str: string) => new Date(str),
113
- startOfWeek: () => 0,
114
- getDay,
115
- locales,
116
- });
223
+ interface StudentProfile {
224
+ id: number;
225
+ enrollmentId: number;
226
+ nome: string;
227
+ email: string;
228
+ telefone: string;
229
+ matriculadoEm: string;
230
+ progresso: number;
231
+ status: string;
232
+ }
233
+
234
+ function getErrorMessage(error: unknown) {
235
+ if (error instanceof Error && error.message) {
236
+ return error.message;
237
+ }
238
+
239
+ if (typeof error === 'object' && error && 'message' in error) {
240
+ const message = (error as { message?: unknown }).message;
241
+ if (typeof message === 'string' && message.trim()) {
242
+ return message;
243
+ }
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ function getPersonInitials(name: string) {
250
+ return name
251
+ .split(' ')
252
+ .filter(Boolean)
253
+ .map((part) => part[0]?.toUpperCase() ?? '')
254
+ .slice(0, 2)
255
+ .join('');
256
+ }
257
+
258
+ function getSessionStartDate(aula: Aula) {
259
+ const date = parseSessionDate(aula.data);
260
+ const [hour = 0, minute = 0] = aula.horaInicio.split(':').map(Number);
261
+
262
+ return setMinutes(setHours(date, hour), minute);
263
+ }
264
+
265
+ function getSessionEndDate(aula: Aula) {
266
+ const date = parseSessionDate(aula.data);
267
+ const [hour = 0, minute = 0] = aula.horaFim.split(':').map(Number);
268
+
269
+ return setMinutes(setHours(date, hour), minute);
270
+ }
271
+
272
+ function DetailMetaItem({
273
+ icon,
274
+ label,
275
+ value,
276
+ action,
277
+ }: {
278
+ icon: ReactNode;
279
+ label: string;
280
+ value: ReactNode;
281
+ action?: ReactNode;
282
+ }) {
283
+ return (
284
+ <div className="rounded-xl border border-border/70 bg-background/70 px-3 py-2.5">
285
+ <div className="mb-1.5 flex items-center gap-2">
286
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/50 text-muted-foreground">
287
+ {icon}
288
+ </div>
289
+ <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
290
+ {label}
291
+ </span>
292
+ </div>
293
+ <div className="flex items-center justify-between gap-3">
294
+ <div className="min-w-0 text-sm font-medium text-foreground">
295
+ {value}
296
+ </div>
297
+ {action}
298
+ </div>
299
+ </div>
300
+ );
301
+ }
302
+
303
+ function OperationalSidebarCard({
304
+ title,
305
+ children,
306
+ className,
307
+ }: {
308
+ title: string;
309
+ children: ReactNode;
310
+ className?: string;
311
+ }) {
312
+ return (
313
+ <Card className={cn('overflow-hidden border-border/70 py-0', className)}>
314
+ <CardContent className="space-y-3 p-4">
315
+ <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
316
+ {title}
317
+ </div>
318
+ {children}
319
+ </CardContent>
320
+ </Card>
321
+ );
322
+ }
323
+
324
+ type ClassDetail = {
325
+ id: number;
326
+ code?: string;
327
+ codigo?: string;
328
+ courseId?: number;
329
+ cursoId?: number;
330
+ courseTitle?: string;
331
+ curso?: string;
332
+ status?: string;
333
+ capacity?: number;
334
+ startDate?: string;
335
+ dataInicio?: string;
336
+ endDate?: string;
337
+ dataFim?: string;
338
+ startTime?: string;
339
+ endTime?: string;
340
+ horario?: string;
341
+ deliveryMode?: 'presential' | 'online' | 'hybrid';
342
+ tipo?: 'presencial' | 'online' | 'hibrida';
343
+ virtualRoomUrl?: string;
344
+ local?: string;
345
+ location?: string;
346
+ };
347
+
348
+ type AttendanceRecord = {
349
+ student_id: number;
350
+ present: boolean;
351
+ };
352
+
353
+ type Locale = {
354
+ id?: number;
355
+ code: string;
356
+ name: string;
357
+ };
358
+
359
+ const EMPTY_AULAS: Aula[] = [];
360
+
361
+ const SESSION_RECURRENCE_FREQUENCIES = [
362
+ 'none',
363
+ 'daily',
364
+ 'weekly',
365
+ 'monthly',
366
+ 'yearly',
367
+ ] as const;
368
+
369
+ const SESSION_RECURRENCE_DAYS = [
370
+ 'MO',
371
+ 'TU',
372
+ 'WE',
373
+ 'TH',
374
+ 'FR',
375
+ 'SA',
376
+ 'SU',
377
+ ] as const;
378
+
379
+ const SESSION_COLOR_PALETTE = [
380
+ '#3b82f6',
381
+ '#22c55e',
382
+ '#f59e0b',
383
+ '#ef4444',
384
+ '#8b5cf6',
385
+ '#06b6d4',
386
+ '#f97316',
387
+ '#64748b',
388
+ ] as const;
389
+
390
+ const SESSION_DEFAULT_COLOR = SESSION_COLOR_PALETTE[0];
391
+
392
+ const getAulaDisplayColor = (aula?: {
393
+ cor?: string | null;
394
+ color?: string | null;
395
+ tipo?: 'presencial' | 'online';
396
+ }) => {
397
+ if (aula?.cor) return aula.cor;
398
+ if (aula?.color) return aula.color;
399
+
400
+ return aula?.tipo === 'presencial' ? '#3b82f6' : '#22c55e';
401
+ };
402
+
403
+ const RECURRENCE_DAY_LABELS: Array<{
404
+ value: SessionRecurrenceDay;
405
+ labelKey: string;
406
+ }> = [
407
+ { value: 'MO', labelKey: 'sheet.lessonForm.recurrence.days.monday' },
408
+ { value: 'TU', labelKey: 'sheet.lessonForm.recurrence.days.tuesday' },
409
+ { value: 'WE', labelKey: 'sheet.lessonForm.recurrence.days.wednesday' },
410
+ { value: 'TH', labelKey: 'sheet.lessonForm.recurrence.days.thursday' },
411
+ { value: 'FR', labelKey: 'sheet.lessonForm.recurrence.days.friday' },
412
+ { value: 'SA', labelKey: 'sheet.lessonForm.recurrence.days.saturday' },
413
+ { value: 'SU', labelKey: 'sheet.lessonForm.recurrence.days.sunday' },
414
+ ];
415
+
416
+ const parseSessionDate = (value: string) => {
417
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value ?? '');
418
+
419
+ if (match) {
420
+ const year = Number(match[1]);
421
+ const month = Number(match[2]);
422
+ const day = Number(match[3]);
423
+
424
+ if (
425
+ Number.isFinite(year) &&
426
+ Number.isFinite(month) &&
427
+ Number.isFinite(day)
428
+ ) {
429
+ return new Date(year, month - 1, day, 12, 0, 0, 0);
430
+ }
431
+ }
432
+
433
+ return new Date(value);
434
+ };
435
+
436
+ const toSessionInputDate = (value: string) => {
437
+ const date = parseSessionDate(value);
438
+ return format(date, 'yyyy-MM-dd');
439
+ };
440
+
441
+ const getDayCodeFromDate = (value: string): SessionRecurrenceDay => {
442
+ const day = parseSessionDate(value).getDay();
443
+ return ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][
444
+ day
445
+ ] as SessionRecurrenceDay;
446
+ };
447
+
448
+ const buildSessionRecurrence = (data: {
449
+ data: string;
450
+ recurrenceFrequency: SessionRecurrenceFrequency;
451
+ recurrenceUntil?: string;
452
+ recurrenceDaysOfWeek?: SessionRecurrenceDay[];
453
+ }) => {
454
+ if (data.recurrenceFrequency === 'none') {
455
+ return undefined;
456
+ }
457
+
458
+ return {
459
+ frequency: data.recurrenceFrequency,
460
+ interval: 1,
461
+ until: data.recurrenceUntil,
462
+ ...(data.recurrenceFrequency === 'weekly'
463
+ ? {
464
+ daysOfWeek:
465
+ data.recurrenceDaysOfWeek && data.recurrenceDaysOfWeek.length > 0
466
+ ? data.recurrenceDaysOfWeek
467
+ : [getDayCodeFromDate(data.data)],
468
+ }
469
+ : {}),
470
+ };
471
+ };
472
+
473
+ function normalizeInstructorOption(
474
+ item: InstructorApiRow
475
+ ): InstructorOption | null {
476
+ const id = Number(item?.id ?? item?.instructor_id ?? item?.value ?? 0);
477
+ const name = String(
478
+ item?.name ?? item?.nome ?? item?.full_name ?? item?.label ?? ''
479
+ ).trim();
480
+
481
+ if (!id || !name) {
482
+ return null;
483
+ }
484
+
485
+ return {
486
+ id,
487
+ name,
488
+ personId: Number(item?.personId ?? item?.person_id ?? 0) || undefined,
489
+ qualificationSlugs: Array.isArray(item?.qualificationSlugs)
490
+ ? item.qualificationSlugs
491
+ : undefined,
492
+ };
493
+ }
117
494
 
118
495
  // ── Schemas ───────────────────────────────────────────────────────────────────
119
496
 
120
497
  const getAulaSchema = (t: (key: string) => string) =>
498
+ z
499
+ .object({
500
+ titulo: z.string().min(3, t('sheet.lessonForm.validation.titleMin')),
501
+ data: z.string().min(1, t('sheet.lessonForm.validation.dateRequired')),
502
+ horaInicio: z
503
+ .string()
504
+ .min(1, t('sheet.lessonForm.validation.startTimeRequired')),
505
+ horaFim: z
506
+ .string()
507
+ .min(1, t('sheet.lessonForm.validation.endTimeRequired')),
508
+ local: z
509
+ .string()
510
+ .min(1, t('sheet.lessonForm.validation.locationRequired')),
511
+ tipo: z.string().min(1, t('sheet.lessonForm.validation.typeRequired')),
512
+ instrutorId: z.string().optional(),
513
+ recurrenceFrequency: z
514
+ .enum(SESSION_RECURRENCE_FREQUENCIES)
515
+ .default('none'),
516
+ recurrenceUntil: z.string().optional(),
517
+ recurrenceDaysOfWeek: z
518
+ .array(z.enum(SESSION_RECURRENCE_DAYS))
519
+ .default([]),
520
+ cor: z
521
+ .string()
522
+ .regex(
523
+ /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/,
524
+ t('sheet.lessonForm.validation.colorInvalid')
525
+ )
526
+ .default(SESSION_DEFAULT_COLOR),
527
+ applyScope: z.enum(['single', 'series']).default('single'),
528
+ })
529
+ .superRefine((values, context) => {
530
+ if (values.recurrenceFrequency === 'none') {
531
+ return;
532
+ }
533
+
534
+ if (!values.recurrenceUntil) {
535
+ context.addIssue({
536
+ code: z.ZodIssueCode.custom,
537
+ message: t('sheet.lessonForm.validation.recurrenceUntilRequired'),
538
+ path: ['recurrenceUntil'],
539
+ });
540
+ return;
541
+ }
542
+
543
+ if (values.recurrenceUntil < values.data) {
544
+ context.addIssue({
545
+ code: z.ZodIssueCode.custom,
546
+ message: t('sheet.lessonForm.validation.recurrenceUntilAfterStart'),
547
+ path: ['recurrenceUntil'],
548
+ });
549
+ }
550
+ });
551
+
552
+ const getStudentSchema = (t: (key: string) => string) =>
121
553
  z.object({
122
- titulo: z.string().min(3, t('sheet.lessonForm.validation.titleMin')),
123
- data: z.string().min(1, t('sheet.lessonForm.validation.dateRequired')),
124
- horaInicio: z
554
+ name: z.string().trim().min(3, t('dialogs.studentValidation.nameMin')),
555
+ email: z
125
556
  .string()
126
- .min(1, t('sheet.lessonForm.validation.startTimeRequired')),
127
- horaFim: z
557
+ .trim()
558
+ .max(255)
559
+ .refine(
560
+ (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
561
+ t('dialogs.studentValidation.emailInvalid')
562
+ ),
563
+ phone: z
128
564
  .string()
129
- .min(1, t('sheet.lessonForm.validation.endTimeRequired')),
130
- local: z.string().min(1, t('sheet.lessonForm.validation.locationRequired')),
131
- tipo: z.string().min(1, t('sheet.lessonForm.validation.typeRequired')),
565
+ .trim()
566
+ .max(50)
567
+ .refine(
568
+ (value) => !value || /^[0-9+()\s-]{8,20}$/.test(value),
569
+ t('dialogs.studentValidation.phoneInvalid')
570
+ ),
132
571
  });
133
572
 
134
573
  type AulaForm = z.infer<ReturnType<typeof getAulaSchema>>;
574
+ type StudentForm = z.infer<ReturnType<typeof getStudentSchema>>;
135
575
 
136
- // ── Mock Data ─────────────────────────────────────────────────────────────────
137
-
138
- const TURMA_MOCK = {
139
- id: 1,
140
- codigo: 'T2024-001',
141
- curso: 'React Avancado',
142
- cursoId: 1,
143
- tipo: 'online' as const,
144
- dataInicio: '2024-02-01',
145
- dataFim: '2024-06-30',
146
- horario: '19:00 - 22:00',
147
- status: 'em_andamento' as const,
148
- vagas: 30,
149
- matriculados: 28,
150
- professor: 'Prof. Marcos Silva',
151
- local: 'https://meet.google.com/abc-defg-hij',
152
- descricao:
153
- 'Turma focada em conceitos avancados de React, incluindo hooks customizados, performance e arquitetura.',
154
- };
155
-
156
- function generateAlunos(): Aluno[] {
157
- const nomes = [
158
- 'Ana Silva',
159
- 'Bruno Costa',
160
- 'Carla Oliveira',
161
- 'Daniel Santos',
162
- 'Elena Ferreira',
163
- 'Felipe Souza',
164
- 'Gabriela Lima',
165
- 'Henrique Almeida',
166
- 'Isabela Rocha',
167
- 'João Pedro',
168
- 'Katia Martins',
169
- 'Lucas Ribeiro',
170
- 'Maria Clara',
171
- 'Nicolas Pereira',
172
- 'Olivia Gomes',
173
- 'Paulo Henrique',
174
- 'Raquel Dias',
175
- 'Samuel Nunes',
176
- 'Tatiana Vieira',
177
- 'Vinicius Castro',
178
- 'William Araújo',
179
- 'Yasmin Barbosa',
180
- 'Zeca Mendes',
181
- 'Amanda Torres',
182
- 'Bruno Lopes',
183
- 'Camila Ramos',
184
- 'Diego Farias',
185
- 'Eduarda Moreira',
186
- ];
187
- return nomes.map((nome, i) => ({
188
- id: i + 1,
189
- nome,
190
- email: `${nome.toLowerCase().replace(' ', '.')}@email.com`,
191
- telefone: `(11) 9${Math.floor(Math.random() * 9000 + 1000)}-${Math.floor(Math.random() * 9000 + 1000)}`,
192
- matriculadoEm: `2024-0${Math.floor(Math.random() * 2 + 1)}-${String(Math.floor(Math.random() * 28 + 1)).padStart(2, '0')}`,
193
- progresso: Math.floor(Math.random() * 60 + 40),
194
- presenca: Math.floor(Math.random() * 30 + 70),
195
- }));
196
- }
197
-
198
- function generateAulas(): Aula[] {
199
- const today = new Date();
200
- const aulas: Aula[] = [];
201
- const titulos = [
202
- 'Introducao a Hooks',
203
- 'useEffect Avancado',
204
- 'Context API',
205
- 'Redux vs Zustand',
206
- 'Performance Optimization',
207
- 'React Query',
208
- 'Testing com Jest',
209
- 'Storybook',
210
- 'Next.js Fundamentos',
211
- 'SSR vs SSG',
212
- 'API Routes',
213
- 'Deploy e CI/CD',
214
- ];
215
- for (let i = -10; i < 20; i++) {
216
- const dia = addDays(today, i);
217
- if (dia.getDay() === 0 || dia.getDay() === 6) continue;
218
- const titulo = titulos[aulas.length % titulos.length]!;
219
- aulas.push({
220
- id: aulas.length + 1,
221
- titulo,
222
- data: dia,
223
- horaInicio: '19:00',
224
- horaFim: '22:00',
225
- local: i % 3 === 0 ? 'Sala 201' : 'https://meet.google.com/abc-defg-hij',
226
- tipo: i % 3 === 0 ? 'presencial' : 'online',
227
- });
228
- }
229
- return aulas;
230
- }
231
-
232
- const ALUNOS_DISPONIVEIS = [
233
- { id: 101, nome: 'Fernando Moura', email: 'fernando.moura@email.com' },
234
- { id: 102, nome: 'Juliana Cardoso', email: 'juliana.cardoso@email.com' },
235
- { id: 103, nome: 'Roberto Freitas', email: 'roberto.freitas@email.com' },
236
- { id: 104, nome: 'Simone Andrade', email: 'simone.andrade@email.com' },
237
- { id: 105, nome: 'Thiago Monteiro', email: 'thiago.monteiro@email.com' },
238
- ];
576
+ // (mock data removed – all data comes from the API)
239
577
 
240
578
  // ── Main Component ────────────────────────────────────────────────────────────
241
579
 
@@ -247,7 +585,6 @@ export default function TurmaDetalhePage() {
247
585
  const router = useRouter();
248
586
  const id = params.id as string;
249
587
  const dateLocale = locale === 'pt' ? ptBR : enUS;
250
- const calendarCulture = locale === 'pt' ? 'pt-BR' : 'en-US';
251
588
 
252
589
  const calendarMessages = {
253
590
  today: t('calendar.today'),
@@ -264,38 +601,196 @@ export default function TurmaDetalhePage() {
264
601
  showMore: (count: number) => t('calendar.showMore', { count }),
265
602
  };
266
603
 
267
- // Data
268
- const [loading, setLoading] = useState(true);
269
- const [turma] = useState(TURMA_MOCK);
270
- const [alunos, setAlunos] = useState<Aluno[]>([]);
271
- const [aulas, setAulas] = useState<Aula[]>([]);
604
+ // ── API ───────────────────────────────────────────────────────────────────
605
+ const { request } = useApp();
606
+
607
+ const {
608
+ data: turma,
609
+ isLoading: loadingTurma,
610
+ refetch: refetchTurma,
611
+ } = useQuery<ClassDetail>({
612
+ queryKey: ['lms-class', id],
613
+ queryFn: async () => {
614
+ const res = await request<ClassDetail>({
615
+ url: `/lms/classes/${id}`,
616
+ method: 'GET',
617
+ });
618
+ return res.data;
619
+ },
620
+ enabled: !!id,
621
+ });
622
+
623
+ const {
624
+ data: alunos = [],
625
+ isLoading: loadingAlunos,
626
+ refetch: refetchAlunos,
627
+ } = useQuery<Aluno[]>({
628
+ queryKey: ['lms-class-students', id],
629
+ queryFn: async () => {
630
+ const res = await request<Aluno[]>({
631
+ url: `/lms/classes/${id}/students`,
632
+ method: 'GET',
633
+ });
634
+ return res.data;
635
+ },
636
+ enabled: !!id,
637
+ });
638
+
639
+ const {
640
+ data: aulasQuery = EMPTY_AULAS,
641
+ isLoading: loadingAulas,
642
+ refetch: refetchAulas,
643
+ } = useQuery<Aula[]>({
644
+ queryKey: ['lms-class-sessions', id],
645
+ queryFn: async () => {
646
+ const res = await request<Aula[]>({
647
+ url: `/lms/classes/${id}/sessions`,
648
+ method: 'GET',
649
+ });
650
+ return res.data;
651
+ },
652
+ enabled: !!id,
653
+ });
654
+
655
+ const loading = loadingTurma || loadingAlunos || loadingAulas;
272
656
 
273
- // Tabs
657
+ // ── Edit sheet state ──────────────────────────────────────────────────────
658
+ const [editSheetOpen, setEditSheetOpen] = useState(false);
659
+
660
+ // ── Tab state ─────────────────────────────────────────────────────────────
274
661
  const [activeTab, setActiveTab] = useState('alunos');
662
+ const [calendarViewMode, setCalendarViewMode] = useState<
663
+ 'single' | 'quarter' | 'year' | 'list'
664
+ >('single');
665
+ const [calendarViewDate, setCalendarViewDate] = useState(() => new Date());
275
666
 
276
- // Alunos
667
+ // ── Students state ────────────────────────────────────────────────────────
277
668
  const [alunoSearch, setAlunoSearch] = useState('');
278
669
  const [selectedAlunos, setSelectedAlunos] = useState<number[]>([]);
279
- const [addAlunoDialogOpen, setAddAlunoDialogOpen] = useState(false);
280
670
  const [removeAlunoDialogOpen, setRemoveAlunoDialogOpen] = useState(false);
281
671
  const [alunoToRemove, setAlunoToRemove] = useState<Aluno | null>(null);
282
- const [alunosToAdd, setAlunosToAdd] = useState<number[]>([]);
672
+ const [pessoaSearch, setPessoaSearch] = useState('');
673
+ const [studentPickerOpen, setStudentPickerOpen] = useState(false);
674
+ const [studentPickerResults, setStudentPickerResults] = useState<Person[]>(
675
+ []
676
+ );
677
+ const [selectedPessoas, setSelectedPessoas] = useState<number[]>([]);
678
+ const [savingStudents, setSavingStudents] = useState(false);
679
+ const [removingStudent, setRemovingStudent] = useState(false);
680
+ const [createStudentDialogOpen, setCreateStudentDialogOpen] = useState(false);
681
+ const [studentProfileDialogOpen, setStudentProfileDialogOpen] =
682
+ useState(false);
683
+ const [editStudentSheetOpen, setEditStudentSheetOpen] = useState(false);
684
+ const [selectedStudentProfile, setSelectedStudentProfile] =
685
+ useState<StudentProfile | null>(null);
686
+ const [loadingStudentProfile, setLoadingStudentProfile] = useState(false);
687
+ const [savingStudentProfile, setSavingStudentProfile] = useState(false);
688
+
689
+ const { data: pessoasDisponiveis = [], isLoading: searchingPessoas } =
690
+ useQuery<Person[]>({
691
+ queryKey: ['lms-people-search', id, pessoaSearch],
692
+ queryFn: async () => {
693
+ const res = await request<Person[]>({
694
+ url: `/lms/classes/${id}/people/search`,
695
+ method: 'GET',
696
+ params: { q: pessoaSearch },
697
+ });
698
+ return res.data;
699
+ },
700
+ enabled: studentPickerOpen,
701
+ placeholderData: (previous) => previous ?? [],
702
+ });
283
703
 
284
- // Calendario
285
- const [calendarView, setCalendarView] = useState<View>('month');
286
- const [calendarDate, setCalendarDate] = useState(new Date());
704
+ // ── Calendar state ────────────────────────────────────────────────────────
287
705
  const [aulaSheetOpen, setAulaSheetOpen] = useState(false);
706
+ const [aulaSheetTab, setAulaSheetTab] = useState<'aulas' | 'chamada'>(
707
+ 'aulas'
708
+ );
288
709
  const [editingAula, setEditingAula] = useState<Aula | null>(null);
289
- const [selectedAulaForPresenca, setSelectedAulaForPresenca] =
290
- useState<Aula | null>(null);
710
+ const [savingAula, setSavingAula] = useState(false);
711
+ const [instructorOpen, setInstructorOpen] = useState(false);
712
+ const [instructorSearch, setInstructorSearch] = useState('');
713
+ const [createInstructorDialogOpen, setCreateInstructorDialogOpen] =
714
+ useState(false);
715
+
716
+ const {
717
+ data: instructorOptions = [],
718
+ isFetching: loadingInstructors,
719
+ refetch: refetchInstructorOptions,
720
+ } = useQuery<InstructorOption[]>({
721
+ queryKey: ['lms-class-session-instructors', id, instructorSearch],
722
+ queryFn: async () => {
723
+ const response = await request<
724
+ | InstructorApiRow[]
725
+ | {
726
+ data?: InstructorApiRow[];
727
+ items?: InstructorApiRow[];
728
+ rows?: InstructorApiRow[];
729
+ }
730
+ >({
731
+ url: '/lms/instructors',
732
+ method: 'GET',
733
+ params: {
734
+ page: 1,
735
+ pageSize: 100,
736
+ qualificationSlugs: ['class-sessions'],
737
+ ...(instructorSearch.trim()
738
+ ? { search: instructorSearch.trim() }
739
+ : {}),
740
+ },
741
+ });
742
+
743
+ const payload = response.data;
744
+ const rows = Array.isArray(payload)
745
+ ? payload
746
+ : Array.isArray(payload?.data)
747
+ ? payload.data
748
+ : Array.isArray(payload?.items)
749
+ ? payload.items
750
+ : Array.isArray(payload?.rows)
751
+ ? payload.rows
752
+ : [];
753
+
754
+ const unique = new Map<number, InstructorOption>();
755
+
756
+ for (const row of rows) {
757
+ const normalized = normalizeInstructorOption(row);
758
+ if (!normalized) continue;
759
+ unique.set(normalized.id, normalized);
760
+ }
291
761
 
292
- // Presenca
293
- const [presencaSheetOpen, setPresencaSheetOpen] = useState(false);
762
+ return Array.from(unique.values()).sort((a, b) =>
763
+ a.name.localeCompare(b.name)
764
+ );
765
+ },
766
+ initialData: [],
767
+ });
768
+
769
+ useEffect(() => {
770
+ if (aulaSheetOpen || instructorOpen) {
771
+ void refetchInstructorOptions();
772
+ }
773
+ }, [aulaSheetOpen, instructorOpen, refetchInstructorOptions]);
774
+
775
+ // ── Attendance state ──────────────────────────────────────────────────────
294
776
  const [presencaList, setPresencaList] = useState<PresencaItem[]>([]);
295
777
  const [savingPresenca, setSavingPresenca] = useState(false);
778
+ const [aulasState, setAulasState] = useState<Aula[]>([]);
779
+
780
+ useEffect(() => {
781
+ setAulasState(
782
+ [...aulasQuery].sort(
783
+ (a, b) =>
784
+ parseSessionDate(a.data).getTime() -
785
+ parseSessionDate(b.data).getTime()
786
+ )
787
+ );
788
+ }, [aulasQuery]);
296
789
 
297
- // Form
790
+ // ── Form ──────────────────────────────────────────────────────────────────
298
791
  const aulaSchema = getAulaSchema(t);
792
+ const studentSchema = getStudentSchema(t);
793
+
299
794
  const aulaForm = useForm<AulaForm>({
300
795
  resolver: zodResolver(aulaSchema),
301
796
  defaultValues: {
@@ -305,20 +800,29 @@ export default function TurmaDetalhePage() {
305
800
  horaFim: '22:00',
306
801
  local: '',
307
802
  tipo: 'online',
803
+ instrutorId: '',
804
+ recurrenceFrequency: 'none',
805
+ recurrenceUntil: '',
806
+ recurrenceDaysOfWeek: [],
807
+ cor: SESSION_DEFAULT_COLOR,
808
+ applyScope: 'single',
308
809
  },
309
810
  });
310
811
 
311
- // Load data
312
- useEffect(() => {
313
- const timer = setTimeout(() => {
314
- setAlunos(generateAlunos());
315
- setAulas(generateAulas());
316
- setLoading(false);
317
- }, 800);
318
- return () => clearTimeout(timer);
319
- }, []);
320
-
321
- // Filter alunos
812
+ const recurrenceFrequency = aulaForm.watch('recurrenceFrequency');
813
+ const recurrenceDaysOfWeek = aulaForm.watch('recurrenceDaysOfWeek') ?? [];
814
+ const showRecurrenceFields = !editingAula || editingAula.isRecurring;
815
+
816
+ const editStudentForm = useForm<StudentForm>({
817
+ resolver: zodResolver(studentSchema),
818
+ defaultValues: {
819
+ name: '',
820
+ email: '',
821
+ phone: '',
822
+ },
823
+ });
824
+
825
+ // ── Derived ───────────────────────────────────────────────────────────────
322
826
  const filteredAlunos = useMemo(() => {
323
827
  if (!alunoSearch.trim()) return alunos;
324
828
  const q = alunoSearch.toLowerCase();
@@ -328,257 +832,649 @@ export default function TurmaDetalhePage() {
328
832
  );
329
833
  }, [alunos, alunoSearch]);
330
834
 
331
- // Calendar events
332
- const calendarEvents = useMemo(() => {
333
- return aulas.map((aula) => {
334
- const [startHour = 0, startMinute = 0] = aula.horaInicio
335
- .split(':')
336
- .map(Number);
337
- const [endHour = 0, endMinute = 0] = aula.horaFim.split(':').map(Number);
338
- return {
339
- id: aula.id,
340
- title: aula.titulo,
341
- start: setMinutes(setHours(aula.data, startHour), startMinute),
342
- end: setMinutes(setHours(aula.data, endHour), endMinute),
343
- resource: aula,
344
- };
345
- });
346
- }, [aulas]);
347
-
348
- // Event style
349
- const eventStyleGetter = useCallback(
350
- (event: { resource?: Aula }) => ({
351
- style: {
352
- backgroundColor:
353
- event.resource?.tipo === 'presencial' ? '#3b82f6' : '#22c55e',
354
- border: 'none',
355
- borderRadius: '6px',
356
- color: '#fff',
357
- fontSize: '0.75rem',
358
- fontWeight: 500,
359
- padding: '3px 8px',
360
- boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
361
- },
362
- }),
363
- []
835
+ const pessoasElegiveisParaMatricula = useMemo(
836
+ () =>
837
+ pessoasDisponiveis.filter((person) => {
838
+ const isInstructor = Boolean(person.isInstructor);
839
+ const canTeachCourses = Boolean(person.canTeachCourses);
840
+ const isStudentByEnrollment = Boolean(person.isStudentByEnrollment);
841
+
842
+ // Teacher-only profiles stay out of the student enrollment picker.
843
+ return !(isInstructor && canTeachCourses && !isStudentByEnrollment);
844
+ }),
845
+ [pessoasDisponiveis]
846
+ );
847
+ const studentPickerResultsById = useMemo(
848
+ () => new Map(studentPickerResults.map((person) => [person.id, person])),
849
+ [studentPickerResults]
364
850
  );
365
851
 
366
- // Handlers
367
- const toggleSelectAluno = (id: number, e?: React.MouseEvent) => {
852
+ useEffect(() => {
853
+ if (pessoasElegiveisParaMatricula.length === 0) return;
854
+
855
+ setStudentPickerResults((previous) => {
856
+ const merged = new Map(previous.map((person) => [person.id, person]));
857
+
858
+ for (const person of pessoasElegiveisParaMatricula) {
859
+ merged.set(person.id, person);
860
+ }
861
+
862
+ return Array.from(merged.values());
863
+ });
864
+ }, [pessoasElegiveisParaMatricula]);
865
+
866
+ const calendarEvents = useMemo(() => {
867
+ return aulasState.map((aula) => ({
868
+ id: aula.id,
869
+ title: aula.titulo,
870
+ start: getSessionStartDate(aula),
871
+ end: getSessionEndDate(aula),
872
+ resource: aula,
873
+ }));
874
+ }, [aulasState]);
875
+
876
+ const sessionsByDay = useMemo(() => {
877
+ const map = new Map<string, Aula[]>();
878
+ for (const aula of aulasState) {
879
+ const key = format(parseSessionDate(aula.data), 'yyyy-MM-dd');
880
+ if (!map.has(key)) map.set(key, []);
881
+ map.get(key)!.push(aula);
882
+ }
883
+ return map;
884
+ }, [aulasState]);
885
+
886
+ const notifyLmsDataUpdated = () => {
887
+ if (typeof window === 'undefined') return;
888
+
889
+ window.sessionStorage.setItem('lms:classes-needs-refresh', '1');
890
+ window.dispatchEvent(new CustomEvent('lms:classes-updated'));
891
+ window.sessionStorage.setItem('lms:dashboard-needs-refresh', '1');
892
+ window.dispatchEvent(new CustomEvent('lms:dashboard-updated'));
893
+ };
894
+
895
+ // ── Handlers ─────────────────────────────────────────────────────────────
896
+ const toggleSelectAluno = (studentId: number, e?: React.MouseEvent) => {
368
897
  if (e?.shiftKey && selectedAlunos.length > 0) {
369
898
  const lastSelected = selectedAlunos[selectedAlunos.length - 1];
370
899
  const lastIndex = filteredAlunos.findIndex((a) => a.id === lastSelected);
371
- const currentIndex = filteredAlunos.findIndex((a) => a.id === id);
900
+ const currentIndex = filteredAlunos.findIndex((a) => a.id === studentId);
372
901
  const [start, end] = [
373
902
  Math.min(lastIndex, currentIndex),
374
903
  Math.max(lastIndex, currentIndex),
375
904
  ];
376
905
  const range = filteredAlunos.slice(start, end + 1).map((a) => a.id);
377
906
  setSelectedAlunos((prev) => [...new Set([...prev, ...range])]);
378
- } else if (e?.ctrlKey || e?.metaKey) {
379
- setSelectedAlunos((prev) =>
380
- prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
381
- );
382
907
  } else {
383
908
  setSelectedAlunos((prev) =>
384
- prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
909
+ prev.includes(studentId)
910
+ ? prev.filter((x) => x !== studentId)
911
+ : [...prev, studentId]
385
912
  );
386
913
  }
387
914
  };
388
915
 
389
- const handleAddAlunos = () => {
390
- if (alunosToAdd.length === 0) return;
391
- const novosAlunos = ALUNOS_DISPONIVEIS.filter((a) =>
392
- alunosToAdd.includes(a.id)
393
- ).map((a) => ({
394
- ...a,
395
- telefone: '(11) 99999-9999',
396
- matriculadoEm: format(new Date(), 'yyyy-MM-dd'),
397
- progresso: 0,
398
- presenca: 100,
399
- }));
400
- setAlunos((prev) => [...prev, ...novosAlunos]);
401
- setAddAlunoDialogOpen(false);
402
- setAlunosToAdd([]);
403
- toast.success(t('toasts.studentsAdded', { count: novosAlunos.length }));
916
+ const handleAddAlunos = async () => {
917
+ if (selectedPessoas.length === 0) return;
918
+
919
+ const selectedEligiblePeople = selectedPessoas.filter((personId) =>
920
+ studentPickerResultsById.has(personId)
921
+ );
922
+
923
+ if (selectedEligiblePeople.length === 0) {
924
+ toast.error('Nenhuma pessoa elegivel foi encontrada para matricula.');
925
+ return;
926
+ }
927
+
928
+ setSavingStudents(true);
929
+ try {
930
+ await Promise.all(
931
+ selectedEligiblePeople.map((personId) =>
932
+ request({
933
+ url: `/lms/classes/${id}/students`,
934
+ method: 'POST',
935
+ data: { personId },
936
+ })
937
+ )
938
+ );
939
+ await refetchAlunos();
940
+ notifyLmsDataUpdated();
941
+ setStudentPickerOpen(false);
942
+ setStudentPickerResults([]);
943
+ setSelectedPessoas([]);
944
+ setPessoaSearch('');
945
+ toast.success(
946
+ t('toasts.studentsAdded', { count: selectedEligiblePeople.length })
947
+ );
948
+ } catch (error: unknown) {
949
+ const message = getErrorMessage(error);
950
+
951
+ if (message?.toLowerCase().includes('already enrolled')) {
952
+ toast.error('Esta pessoa ja esta matriculada na turma.');
953
+ } else {
954
+ toast.error(message || t('toasts.error'));
955
+ }
956
+ } finally {
957
+ setSavingStudents(false);
958
+ }
959
+ };
960
+
961
+ const handleStudentEnrolled = async () => {
962
+ await refetchAlunos();
963
+ notifyLmsDataUpdated();
964
+ setCreateStudentDialogOpen(false);
965
+ setStudentPickerOpen(false);
966
+ setStudentPickerResults([]);
967
+ setSelectedPessoas([]);
968
+ setPessoaSearch('');
969
+ toast.success(t('toasts.studentCreated'));
970
+ };
971
+
972
+ const openCreateStudentDialog = () => {
973
+ setStudentPickerOpen(false);
974
+ setPessoaSearch('');
975
+ setCreateStudentDialogOpen(true);
404
976
  };
405
977
 
406
- const handleRemoveAluno = () => {
978
+ const handleRemoveAluno = async () => {
407
979
  if (!alunoToRemove) return;
408
- setAlunos((prev) => prev.filter((a) => a.id !== alunoToRemove.id));
409
- setRemoveAlunoDialogOpen(false);
410
- setAlunoToRemove(null);
411
- toast.success(t('toasts.studentRemoved'));
980
+ setRemovingStudent(true);
981
+ try {
982
+ await request({
983
+ url: `/lms/classes/${id}/students/${alunoToRemove.id}`,
984
+ method: 'DELETE',
985
+ });
986
+ await refetchAlunos();
987
+ notifyLmsDataUpdated();
988
+ setRemoveAlunoDialogOpen(false);
989
+ setAlunoToRemove(null);
990
+ toast.success(t('toasts.studentRemoved'));
991
+ } catch {
992
+ toast.error(t('toasts.error'));
993
+ } finally {
994
+ setRemovingStudent(false);
995
+ }
412
996
  };
413
997
 
414
- const handleRemoveSelectedAlunos = () => {
415
- setAlunos((prev) => prev.filter((a) => !selectedAlunos.includes(a.id)));
416
- setSelectedAlunos([]);
417
- toast.success(
418
- t('toasts.studentsRemoved', { count: selectedAlunos.length })
419
- );
998
+ const handleRemoveSelectedAlunos = async () => {
999
+ if (selectedAlunos.length === 0) return;
1000
+ try {
1001
+ await Promise.all(
1002
+ selectedAlunos.map((personId) =>
1003
+ request({
1004
+ url: `/lms/classes/${id}/students/${personId}`,
1005
+ method: 'DELETE',
1006
+ })
1007
+ )
1008
+ );
1009
+ await refetchAlunos();
1010
+ notifyLmsDataUpdated();
1011
+ setSelectedAlunos([]);
1012
+ toast.success(
1013
+ t('toasts.studentsRemoved', { count: selectedAlunos.length })
1014
+ );
1015
+ } catch {
1016
+ toast.error(t('toasts.error'));
1017
+ }
1018
+ };
1019
+
1020
+ const openStudentProfile = async (personId: number) => {
1021
+ setLoadingStudentProfile(true);
1022
+ try {
1023
+ const res = await request<StudentProfile>({
1024
+ url: `/lms/classes/${id}/students/${personId}`,
1025
+ method: 'GET',
1026
+ });
1027
+ const profile = res.data;
1028
+ setSelectedStudentProfile(profile);
1029
+ setStudentProfileDialogOpen(true);
1030
+ } catch {
1031
+ toast.error(t('toasts.error'));
1032
+ } finally {
1033
+ setLoadingStudentProfile(false);
1034
+ }
1035
+ };
1036
+
1037
+ const openEditStudentSheet = () => {
1038
+ if (!selectedStudentProfile) return;
1039
+ editStudentForm.reset({
1040
+ name: selectedStudentProfile.nome || '',
1041
+ email: selectedStudentProfile.email || '',
1042
+ phone: selectedStudentProfile.telefone || '',
1043
+ });
1044
+ setStudentProfileDialogOpen(false);
1045
+ setEditStudentSheetOpen(true);
420
1046
  };
421
1047
 
422
- const openAulaSheet = (aula?: Aula) => {
1048
+ const handleUpdateStudentProfile = editStudentForm.handleSubmit(
1049
+ async (values) => {
1050
+ if (!selectedStudentProfile) return;
1051
+
1052
+ setSavingStudentProfile(true);
1053
+ try {
1054
+ const res = await request<StudentProfile>({
1055
+ url: `/lms/classes/${id}/students/${selectedStudentProfile.id}`,
1056
+ method: 'PATCH',
1057
+ data: {
1058
+ name: values.name,
1059
+ email: values.email,
1060
+ phone: values.phone,
1061
+ },
1062
+ });
1063
+
1064
+ setSelectedStudentProfile(res.data);
1065
+ await refetchAlunos();
1066
+ setEditStudentSheetOpen(false);
1067
+ setStudentProfileDialogOpen(true);
1068
+ toast.success(t('toasts.studentUpdated'));
1069
+ } catch {
1070
+ toast.error(t('toasts.error'));
1071
+ } finally {
1072
+ setSavingStudentProfile(false);
1073
+ }
1074
+ }
1075
+ );
1076
+
1077
+ const getDefaultPresencaList = useCallback(
1078
+ () =>
1079
+ alunos.map((a) => ({
1080
+ alunoId: a.id,
1081
+ selecionado: false,
1082
+ presente: false,
1083
+ })),
1084
+ [alunos]
1085
+ );
1086
+
1087
+ const loadPresencaForAula = useCallback(
1088
+ async (aula: Aula) => {
1089
+ setPresencaList(getDefaultPresencaList());
1090
+ try {
1091
+ const res = await request<AttendanceRecord[]>({
1092
+ url: `/lms/classes/${id}/sessions/${aula.id}/attendance`,
1093
+ method: 'GET',
1094
+ });
1095
+ const attendanceData = res.data;
1096
+ setPresencaList(
1097
+ alunos.map((a) => {
1098
+ const found = attendanceData.find((att) => att.student_id === a.id);
1099
+ return {
1100
+ alunoId: a.id,
1101
+ selecionado: Boolean(found),
1102
+ presente: found?.present ?? false,
1103
+ };
1104
+ })
1105
+ );
1106
+ } catch {
1107
+ // Keep default values if attendance cannot be loaded.
1108
+ }
1109
+ },
1110
+ [alunos, getDefaultPresencaList, id, request]
1111
+ );
1112
+
1113
+ const openAulaSheet = (aula?: Aula, options?: OpenAulaSheetOptions) => {
1114
+ setAulaSheetTab(options?.initialTab ?? 'aulas');
1115
+
423
1116
  if (aula) {
424
1117
  setEditingAula(aula);
425
1118
  aulaForm.reset({
426
1119
  titulo: aula.titulo,
427
- data: format(aula.data, 'yyyy-MM-dd'),
1120
+ data: toSessionInputDate(aula.data),
428
1121
  horaInicio: aula.horaInicio,
429
1122
  horaFim: aula.horaFim,
430
- local: aula.local,
1123
+ local: aula.local || aula.meetingUrl || '',
431
1124
  tipo: aula.tipo,
1125
+ instrutorId: aula.instructorId ? String(aula.instructorId) : '',
1126
+ recurrenceFrequency: aula.recurrence?.frequency ?? 'none',
1127
+ recurrenceUntil: aula.recurrence?.until ?? '',
1128
+ recurrenceDaysOfWeek: aula.recurrence?.daysOfWeek ?? [],
1129
+ cor: getAulaDisplayColor(aula),
1130
+ applyScope: 'single',
432
1131
  });
1132
+ void loadPresencaForAula(aula);
433
1133
  } else {
434
1134
  setEditingAula(null);
435
1135
  aulaForm.reset({
436
1136
  titulo: '',
437
1137
  data: '',
438
- horaInicio: '19:00',
439
- horaFim: '22:00',
1138
+ horaInicio: turma?.startTime ?? '19:00',
1139
+ horaFim: turma?.endTime ?? '22:00',
440
1140
  local: '',
441
- tipo: 'online',
1141
+ tipo: turma?.deliveryMode === 'presential' ? 'presencial' : 'online',
1142
+ instrutorId: '',
1143
+ recurrenceFrequency: 'none',
1144
+ recurrenceUntil: '',
1145
+ recurrenceDaysOfWeek: [],
1146
+ cor: SESSION_DEFAULT_COLOR,
1147
+ applyScope: 'single',
1148
+ ...options?.prefill,
442
1149
  });
1150
+ setPresencaList(options?.attendance ?? getDefaultPresencaList());
443
1151
  }
1152
+
444
1153
  setAulaSheetOpen(true);
445
1154
  };
446
1155
 
447
- const handleSaveAula = aulaForm.handleSubmit((data) => {
1156
+ const upsertAula = async (data: AulaForm) => {
1157
+ const isOnline = data.tipo === 'online';
1158
+ const payload = {
1159
+ title: data.titulo,
1160
+ sessionDate: data.data,
1161
+ startTime: data.horaInicio,
1162
+ endTime: data.horaFim,
1163
+ location: isOnline ? undefined : data.local,
1164
+ meetingUrl: isOnline ? data.local : undefined,
1165
+ instructorId: data.instrutorId ? Number(data.instrutorId) : undefined,
1166
+ color: data.cor,
1167
+ recurrence: showRecurrenceFields
1168
+ ? buildSessionRecurrence(data)
1169
+ : undefined,
1170
+ applyScope: editingAula?.isRecurring ? data.applyScope : undefined,
1171
+ };
1172
+
448
1173
  if (editingAula) {
449
- setAulas((prev) =>
450
- prev.map((a) =>
451
- a.id === editingAula.id
452
- ? {
453
- ...a,
454
- titulo: data.titulo,
455
- data: new Date(data.data),
456
- horaInicio: data.horaInicio,
457
- horaFim: data.horaFim,
458
- local: data.local,
459
- tipo: data.tipo as 'presencial' | 'online',
460
- }
461
- : a
462
- )
1174
+ const res = await request<Aula>({
1175
+ url: `/lms/classes/${id}/sessions/${editingAula.id}`,
1176
+ method: 'PATCH',
1177
+ data: payload,
1178
+ });
1179
+ setAulasState((prev) =>
1180
+ prev.map((item) => (item.id === editingAula.id ? res.data : item))
463
1181
  );
464
- toast.success(t('toasts.lessonUpdated'));
465
- } else {
466
- const newAula: Aula = {
467
- id: Math.max(...aulas.map((a) => a.id), 0) + 1,
468
- titulo: data.titulo,
469
- data: new Date(data.data),
470
- horaInicio: data.horaInicio,
471
- horaFim: data.horaFim,
472
- local: data.local,
473
- tipo: data.tipo as 'presencial' | 'online',
474
- };
475
- setAulas((prev) => [...prev, newAula]);
476
- toast.success(t('toasts.lessonCreated'));
1182
+ return { aula: res.data, mode: 'updated' as const };
477
1183
  }
478
- setAulaSheetOpen(false);
479
- });
480
1184
 
481
- const handleSelectEvent = useCallback((event: { resource?: Aula }) => {
482
- if (event.resource) {
483
- openAulaSheet(event.resource);
1185
+ const res = await request<Aula>({
1186
+ url: `/lms/classes/${id}/sessions`,
1187
+ method: 'POST',
1188
+ data: payload,
1189
+ });
1190
+ setAulasState((prev) =>
1191
+ [...prev, res.data].sort(
1192
+ (a, b) =>
1193
+ parseSessionDate(a.data).getTime() -
1194
+ parseSessionDate(b.data).getTime()
1195
+ )
1196
+ );
1197
+
1198
+ return { aula: res.data, mode: 'created' as const };
1199
+ };
1200
+
1201
+ const savePresencaForAula = async (sessionId: number) => {
1202
+ await request({
1203
+ url: `/lms/classes/${id}/sessions/${sessionId}/attendance`,
1204
+ method: 'POST',
1205
+ data: {
1206
+ attendance: presencaList
1207
+ .filter((p) => p.selecionado)
1208
+ .map((p) => ({
1209
+ studentId: p.alunoId,
1210
+ present: p.presente,
1211
+ })),
1212
+ },
1213
+ });
1214
+ };
1215
+
1216
+ const handleSaveAula = aulaForm.handleSubmit(async (data) => {
1217
+ setSavingAula(true);
1218
+ try {
1219
+ const { mode } = await upsertAula(data);
1220
+ toast.success(
1221
+ mode === 'updated'
1222
+ ? t('toasts.lessonUpdated')
1223
+ : t('toasts.lessonCreated')
1224
+ );
1225
+ void refetchAulas();
1226
+ notifyLmsDataUpdated();
1227
+ setAulaSheetOpen(false);
1228
+ } catch {
1229
+ toast.error(t('toasts.error'));
1230
+ } finally {
1231
+ setSavingAula(false);
484
1232
  }
485
- }, []);
1233
+ });
486
1234
 
487
- const openPresenca = (aula: Aula) => {
488
- setSelectedAulaForPresenca(aula);
489
- setPresencaList(
490
- alunos.map((a) => ({ alunoId: a.id, presente: Math.random() > 0.15 }))
1235
+ const handleQuickCreateSaved = (aula: Aula) => {
1236
+ setAulasState((prev) =>
1237
+ [...prev, aula].sort(
1238
+ (a, b) =>
1239
+ parseSessionDate(a.data).getTime() -
1240
+ parseSessionDate(b.data).getTime()
1241
+ )
491
1242
  );
492
- setPresencaSheetOpen(true);
1243
+ void refetchAulas();
1244
+ notifyLmsDataUpdated();
1245
+ };
1246
+
1247
+ const handleInstructorCreated = async (instructor: {
1248
+ id: number;
1249
+ personId: number;
1250
+ name: string;
1251
+ qualificationSlugs: string[];
1252
+ }) => {
1253
+ aulaForm.setValue('instrutorId', String(instructor.id), {
1254
+ shouldDirty: true,
1255
+ shouldTouch: true,
1256
+ shouldValidate: true,
1257
+ });
1258
+
1259
+ await refetchInstructorOptions();
1260
+ };
1261
+
1262
+ const openPresenca = (aula: Aula) => {
1263
+ openAulaSheet(aula, { initialTab: 'chamada' });
493
1264
  };
494
1265
 
495
1266
  const togglePresenca = (alunoId: number) => {
496
1267
  setPresencaList((prev) =>
497
1268
  prev.map((p) =>
498
- p.alunoId === alunoId ? { ...p, presente: !p.presente } : p
1269
+ p.alunoId === alunoId
1270
+ ? { ...p, selecionado: true, presente: !p.presente }
1271
+ : p
499
1272
  )
500
1273
  );
501
1274
  };
502
1275
 
503
- const handleSavePresenca = async () => {
504
- setSavingPresenca(true);
505
- await new Promise((r) => setTimeout(r, 800));
506
- setSavingPresenca(false);
507
- setPresencaSheetOpen(false);
508
- toast.success(t('toasts.attendanceSaved'));
1276
+ const toggleParticipante = (alunoId: number) => {
1277
+ setPresencaList((prev) =>
1278
+ prev.map((p) =>
1279
+ p.alunoId === alunoId
1280
+ ? {
1281
+ ...p,
1282
+ selecionado: !p.selecionado,
1283
+ presente: p.selecionado ? false : p.presente,
1284
+ }
1285
+ : p
1286
+ )
1287
+ );
509
1288
  };
510
1289
 
511
- // KPIs
512
- const kpis = [
513
- {
514
- label: t('kpis.enrolledStudents.label'),
515
- valor: alunos.length,
516
- sub: t('kpis.enrolledStudents.sub', { vagas: turma.vagas }),
517
- icon: Users,
518
- iconBg: 'bg-orange-100',
519
- iconColor: 'text-orange-600',
520
- },
521
- {
522
- label: t('kpis.occupancyRate.label'),
523
- valor: `${Math.round((alunos.length / turma.vagas) * 100)}%`,
524
- sub:
525
- turma.vagas - alunos.length > 0
526
- ? t('kpis.occupancyRate.subFree', {
527
- count: turma.vagas - alunos.length,
528
- })
529
- : t('kpis.occupancyRate.subFull'),
530
- icon: BarChart3,
531
- iconBg: 'bg-muted',
532
- iconColor: 'text-foreground',
1290
+ const handleSaveAulaAndPresenca = aulaForm.handleSubmit(async (data) => {
1291
+ setSavingPresenca(true);
1292
+ setSavingAula(true);
1293
+ try {
1294
+ const { aula } = await upsertAula(data);
1295
+ if (!aula?.id) {
1296
+ throw new Error('Invalid session id');
1297
+ }
1298
+ await savePresencaForAula(aula.id);
1299
+ void refetchAulas();
1300
+ notifyLmsDataUpdated();
1301
+ setAulaSheetOpen(false);
1302
+ toast.success(t('toasts.attendanceSaved'));
1303
+ } catch {
1304
+ toast.error(t('toasts.error'));
1305
+ } finally {
1306
+ setSavingPresenca(false);
1307
+ setSavingAula(false);
1308
+ }
1309
+ });
1310
+
1311
+ const handleSaveAttendanceOnly = async () => {
1312
+ if (!editingAula) {
1313
+ await handleSaveAulaAndPresenca();
1314
+ return;
1315
+ }
1316
+
1317
+ setSavingPresenca(true);
1318
+ try {
1319
+ await savePresencaForAula(editingAula.id);
1320
+ void refetchAulas();
1321
+ notifyLmsDataUpdated();
1322
+ setAulaSheetOpen(false);
1323
+ toast.success(t('toasts.attendanceSaved'));
1324
+ } catch {
1325
+ toast.error(t('toasts.error'));
1326
+ } finally {
1327
+ setSavingPresenca(false);
1328
+ }
1329
+ };
1330
+
1331
+ // ── KPIs ──────────────────────────────────────────────────────────────────
1332
+ const now = new Date();
1333
+ const completedSessions = aulasState.filter(
1334
+ (aula) => getSessionEndDate(aula) < now
1335
+ );
1336
+ const upcomingSessions = aulasState.filter(
1337
+ (aula) => getSessionEndDate(aula) >= now
1338
+ );
1339
+ const nextSession = upcomingSessions[0] ?? null;
1340
+ const completedSessionsCount = completedSessions.length;
1341
+ const totalSessionsCount = aulasState.length;
1342
+ const capacity = turma?.capacity ?? 0;
1343
+ const occupiedSeats = alunos.length;
1344
+ const availableSeats = Math.max(capacity - occupiedSeats, 0);
1345
+ const occupancyRate =
1346
+ capacity > 0 ? Math.round((occupiedSeats / capacity) * 100) : 0;
1347
+ const primaryInstructor =
1348
+ nextSession?.instructorName?.trim() ||
1349
+ aulasState
1350
+ .find((aula) => aula.instructorName?.trim())
1351
+ ?.instructorName?.trim() ||
1352
+ 'Nao definido';
1353
+ const nextSessionStartsAt = nextSession
1354
+ ? getSessionStartDate(nextSession)
1355
+ : null;
1356
+ const nextSessionSummary = nextSessionStartsAt
1357
+ ? format(nextSessionStartsAt, "dd/MM 'as' HH:mm", { locale: dateLocale })
1358
+ : 'Nenhuma aula futura';
1359
+ const nextSessionSupportText = nextSession
1360
+ ? nextSession.titulo
1361
+ : 'Agende a proxima aula para manter a turma em movimento.';
1362
+
1363
+ const kpis: KpiCardItem[] = [
1364
+ {
1365
+ key: 'enrolled-students',
1366
+ title: t('kpis.enrolledStudents.label'),
1367
+ value: occupiedSeats,
1368
+ description: t('kpis.enrolledStudents.sub', {
1369
+ vagas: capacity,
1370
+ }),
1371
+ icon: Users,
1372
+ iconContainerClassName: 'bg-orange-500/10 text-orange-700',
1373
+ accentClassName: 'from-orange-500/25 via-amber-500/10 to-transparent',
1374
+ layout: 'compact',
1375
+ },
1376
+ {
1377
+ key: 'occupancy-rate',
1378
+ title: t('kpis.occupancyRate.label'),
1379
+ value: capacity > 0 ? `${occupancyRate}%` : '—',
1380
+ description:
1381
+ capacity > 0 && availableSeats > 0
1382
+ ? t('kpis.occupancyRate.subFree', {
1383
+ count: availableSeats,
1384
+ })
1385
+ : capacity > 0
1386
+ ? t('kpis.occupancyRate.subFull')
1387
+ : 'Capacidade nao definida',
1388
+ icon: BarChart3,
1389
+ iconContainerClassName: 'bg-sky-500/10 text-sky-700',
1390
+ accentClassName: 'from-sky-500/25 via-blue-500/10 to-transparent',
1391
+ layout: 'compact',
533
1392
  },
534
1393
  {
535
- label: t('kpis.avgAttendance.label'),
536
- valor: `${Math.round(alunos.reduce((a, b) => a + b.presenca, 0) / Math.max(alunos.length, 1))}%`,
537
- sub: t('kpis.avgAttendance.sub'),
538
- icon: CheckCircle2,
539
- iconBg: 'bg-muted',
540
- iconColor: 'text-foreground',
1394
+ key: 'next-session',
1395
+ title: 'Proxima aula',
1396
+ value: nextSession
1397
+ ? format(nextSessionStartsAt!, 'dd/MM', { locale: dateLocale })
1398
+ : 'Sem aula',
1399
+ description: nextSession
1400
+ ? `${nextSession.horaInicio} - ${nextSession.horaFim}`
1401
+ : 'Crie a proxima sessao',
1402
+ icon: Clock,
1403
+ iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
1404
+ accentClassName: 'from-emerald-500/25 via-green-500/10 to-transparent',
1405
+ layout: 'compact',
541
1406
  },
542
1407
  {
543
- label: t('kpis.completedLessons.label'),
544
- valor: aulas.filter((a) => a.data < new Date()).length,
545
- sub: t('kpis.completedLessons.sub', { total: aulas.length }),
1408
+ key: 'calendar',
1409
+ title: 'Calendario',
1410
+ value: totalSessionsCount,
1411
+ description: `${completedSessionsCount} concluidas`,
546
1412
  icon: CalendarIcon,
547
- iconBg: 'bg-muted',
548
- iconColor: 'text-foreground',
1413
+ iconContainerClassName: 'bg-violet-500/10 text-violet-700',
1414
+ accentClassName: 'from-violet-500/25 via-violet-500/10 to-transparent',
1415
+ layout: 'compact',
549
1416
  },
550
1417
  ];
551
1418
 
552
- const STATUS_MAP = {
553
- aberta: {
1419
+ const STATUS_MAP: Record<string, { label: string; color: string }> = {
1420
+ open: {
554
1421
  label: tClasses('status.aberta'),
555
1422
  color: 'bg-blue-100 text-blue-700 border-blue-200',
556
1423
  },
557
- em_andamento: {
1424
+ ongoing: {
558
1425
  label: tClasses('status.em_andamento'),
559
1426
  color: 'bg-emerald-100 text-emerald-700 border-emerald-200',
560
1427
  },
561
- concluida: {
1428
+ completed: {
562
1429
  label: tClasses('status.concluida'),
563
1430
  color: 'bg-gray-100 text-gray-700 border-gray-200',
564
1431
  },
565
- cancelada: {
1432
+ cancelled: {
566
1433
  label: tClasses('status.cancelada'),
567
1434
  color: 'bg-red-100 text-red-700 border-red-200',
568
1435
  },
569
1436
  } as const;
570
- const turmaStatus =
571
- STATUS_MAP[turma.status as keyof typeof STATUS_MAP] ?? STATUS_MAP.aberta;
1437
+ const turmaStatus = turma?.status ?? 'open';
1438
+ const statusInfo = STATUS_MAP[turmaStatus] ?? { label: '', color: '' };
572
1439
 
573
1440
  const fadeUp = {
574
1441
  hidden: { opacity: 0, y: 20 },
575
1442
  visible: { opacity: 1, y: 0 },
576
1443
  };
577
1444
 
1445
+ const classTitle = turma?.courseTitle ?? turma?.curso ?? '';
1446
+ const classCode = turma?.code ?? turma?.codigo ?? '';
1447
+ const courseId = turma?.courseId ?? turma?.cursoId;
1448
+ const startDate = turma?.startDate ?? turma?.dataInicio;
1449
+ const endDate = turma?.endDate ?? turma?.dataFim;
1450
+ const schedule =
1451
+ turma?.startTime && turma?.endTime
1452
+ ? `${turma.startTime} - ${turma.endTime}`
1453
+ : (turma?.horario ?? '—');
1454
+ const deliveryMode = turma?.deliveryMode ?? turma?.tipo ?? 'online';
1455
+ const deliveryTypeKey =
1456
+ deliveryMode === 'presential' ? 'presencial' : deliveryMode;
1457
+ const isOnline = deliveryMode === 'online' || deliveryMode === 'hybrid';
1458
+ const roomUrl = turma?.virtualRoomUrl ?? turma?.local ?? '';
1459
+ const location = turma?.location ?? turma?.local ?? '—';
1460
+
1461
+ const handleViewCourse = (): void => {
1462
+ if (!courseId) {
1463
+ toast.error('Nao foi possivel localizar o curso desta turma.');
1464
+ return;
1465
+ }
1466
+
1467
+ router.push(`/lms/courses/${courseId}`);
1468
+ };
1469
+
1470
+ const handleNewLesson = (): void => {
1471
+ openAulaSheet();
1472
+ };
1473
+
578
1474
  return (
579
1475
  <Page>
580
1476
  <PageHeader
581
- title={turma.curso}
1477
+ title={classTitle}
582
1478
  breadcrumbs={[
583
1479
  {
584
1480
  label: t('breadcrumbs.home'),
@@ -589,21 +1485,79 @@ export default function TurmaDetalhePage() {
589
1485
  href: '/lms/classes',
590
1486
  },
591
1487
  {
592
- label: t('breadcrumbs.management'),
1488
+ label: t('breadcrumbs.managementMobile'),
593
1489
  },
594
1490
  ]}
595
- actions={
596
- <div className="flex items-center gap-2">
597
- <div className="flex gap-2">
598
- <Button variant="outline" asChild>
599
- <Link href={`/lms/courses/${turma.cursoId}`}>
600
- {t('actions.viewCourse')}
601
- </Link>
602
- </Button>
603
- </div>
604
- <Button onClick={() => openAulaSheet()} className="gap-2">
605
- <Plus className="size-4" /> {t('actions.newLesson')}
606
- </Button>
1491
+ actions={[
1492
+ {
1493
+ label: t('actions.viewCourse'),
1494
+ onClick: () => handleViewCourse(),
1495
+ variant: 'outline',
1496
+ },
1497
+ {
1498
+ label: t('actions.newLesson'),
1499
+ onClick: () => handleNewLesson(),
1500
+ variant: 'outline',
1501
+ },
1502
+ {
1503
+ label: 'Editar turma',
1504
+ onClick: () => setEditSheetOpen(true),
1505
+ variant: 'default',
1506
+ },
1507
+ ]}
1508
+ extraContent={
1509
+ <div className="mt-2 flex flex-wrap gap-1.5">
1510
+ <Badge className={cn('border text-[11px] font-medium')}>
1511
+ {classCode}
1512
+ <CopyButton value={classCode} className="ml-1 h-4 w-4" />
1513
+ </Badge>
1514
+ <Badge
1515
+ className={cn(statusInfo.color, 'border text-[11px] font-medium')}
1516
+ >
1517
+ {statusInfo.label}
1518
+ <CopyButton value={statusInfo.label} className="ml-1 h-4 w-4" />
1519
+ </Badge>
1520
+ <Badge variant="outline" className="gap-1 text-[11px]">
1521
+ {isOnline ? (
1522
+ <Video className="h-3 w-3" />
1523
+ ) : (
1524
+ <MapPin className="h-3 w-3" />
1525
+ )}
1526
+ {tClasses(`type.${deliveryTypeKey}`)}
1527
+ <CopyButton
1528
+ value={tClasses(`type.${deliveryTypeKey}`)}
1529
+ className="ml-1 h-4 w-4"
1530
+ />
1531
+ </Badge>
1532
+ {schedule !== '—' && (
1533
+ <Badge variant="outline" className="gap-1 text-[11px]">
1534
+ <Clock className="h-3 w-3" />
1535
+ {schedule}
1536
+ <CopyButton value={schedule} className="ml-1 h-4 w-4" />
1537
+ </Badge>
1538
+ )}
1539
+ {startDate && endDate && (
1540
+ <Badge variant="outline" className="gap-1 text-[11px]">
1541
+ <CalendarIcon className="h-3 w-3" />
1542
+ {format(new Date(startDate), 'dd/MM/yy')}
1543
+ {' – '}
1544
+ {format(new Date(endDate), 'dd/MM/yy')}
1545
+ <CopyButton
1546
+ value={`${format(new Date(startDate), 'dd/MM/yy')} – ${format(new Date(endDate), 'dd/MM/yy')}`}
1547
+ className="ml-1 h-4 w-4"
1548
+ />
1549
+ </Badge>
1550
+ )}
1551
+ {primaryInstructor !== 'Nao definido' && (
1552
+ <Badge variant="outline" className="gap-1 text-[11px]">
1553
+ <Users className="h-3 w-3" />
1554
+ {primaryInstructor}
1555
+ <CopyButton
1556
+ value={primaryInstructor}
1557
+ className="ml-1 h-4 w-4"
1558
+ />
1559
+ </Badge>
1560
+ )}
607
1561
  </div>
608
1562
  }
609
1563
  />
@@ -613,557 +1567,1373 @@ export default function TurmaDetalhePage() {
613
1567
  initial="hidden"
614
1568
  animate="visible"
615
1569
  variants={{ visible: { transition: { staggerChildren: 0.05 } } }}
1570
+ className="space-y-4"
616
1571
  >
617
- {/* Header */}
618
- <motion.div
619
- variants={fadeUp}
620
- className="mb-3 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
621
- >
622
- <div>
623
- <div className="flex flex-wrap items-center gap-2 mb-1">
624
- <Badge className={`${turmaStatus.color} border`}>
625
- {turmaStatus.label}
626
- </Badge>
1572
+ {/* ── KPIs ───────────────────────────────────────────────────────── */}
1573
+ <motion.div variants={fadeUp}>
1574
+ {loading ? (
1575
+ <div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
1576
+ {Array.from({ length: 4 }).map((_, i) => (
1577
+ <Card
1578
+ key={i}
1579
+ className="overflow-hidden border-border/70 py-0"
1580
+ >
1581
+ <div className="h-1 w-full bg-gradient-to-r from-slate-300/70 via-slate-200 to-transparent" />
1582
+ <CardContent className="p-4">
1583
+ <Skeleton className="mb-2 h-8 w-16" />
1584
+ <Skeleton className="h-4 w-28" />
1585
+ </CardContent>
1586
+ </Card>
1587
+ ))}
627
1588
  </div>
628
- <p className="text-muted-foreground">
629
- <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
630
- {turma.codigo}
631
- </code>
632
- <span className="mx-2">|</span>
633
- {turma.professor}
634
- </p>
635
- </div>
1589
+ ) : (
1590
+ <KpiCardsGrid items={kpis} columns={4} />
1591
+ )}
636
1592
  </motion.div>
637
1593
 
638
- {/* KPIs */}
1594
+ {/* ── Main layout: tabs (left) + sidebar (right) ─────────────────── */}
639
1595
  <motion.div
640
1596
  variants={fadeUp}
641
- className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4"
1597
+ className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]"
642
1598
  >
643
- {loading
644
- ? Array.from({ length: 4 }).map((_, i) => (
645
- <Card key={i}>
646
- <CardContent className="p-4">
647
- <Skeleton className="mb-2 h-8 w-16" />
648
- <Skeleton className="h-4 w-28" />
649
- </CardContent>
650
- </Card>
651
- ))
652
- : kpis.map((kpi, i) => (
653
- <motion.div
654
- key={kpi.label}
655
- initial={{ opacity: 0, y: 12 }}
656
- animate={{ opacity: 1, y: 0 }}
657
- transition={{ delay: i * 0.07 }}
658
- >
659
- <Card className="overflow-hidden">
660
- <CardContent className="flex items-start justify-between p-5">
1599
+ {/* ── Left: Tabs ─────────────────────────────────────────────── */}
1600
+ <div className="min-w-0">
1601
+ <Tabs
1602
+ value={activeTab}
1603
+ onValueChange={setActiveTab}
1604
+ className="w-full"
1605
+ >
1606
+ <TabsList className="mb-4 h-auto grid w-full grid-cols-3 rounded-lg bg-muted/80 p-1">
1607
+ <TabsTrigger value="alunos" className="gap-2">
1608
+ <Users className="size-4" />
1609
+ {t('tabs.students')}
1610
+ {!loading && alunos.length > 0 && (
1611
+ <span className="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
1612
+ {alunos.length}
1613
+ </span>
1614
+ )}
1615
+ </TabsTrigger>
1616
+ <TabsTrigger value="calendario" className="gap-2">
1617
+ <CalendarIcon className="size-4" />
1618
+ {t('tabs.calendar')}
1619
+ {!loading && totalSessionsCount > 0 && (
1620
+ <span className="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
1621
+ {totalSessionsCount}
1622
+ </span>
1623
+ )}
1624
+ </TabsTrigger>
1625
+ <TabsTrigger value="presenca" className="gap-2">
1626
+ <CheckCircle2 className="size-4" />
1627
+ {t('tabs.attendance')}
1628
+ {!loading && completedSessionsCount > 0 && (
1629
+ <span className="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
1630
+ {completedSessionsCount}
1631
+ </span>
1632
+ )}
1633
+ </TabsTrigger>
1634
+ </TabsList>
1635
+
1636
+ {/* ── Tab Alunos ───────────────────────────────────────────── */}
1637
+ <TabsContent value="alunos" className="mt-0">
1638
+ {/* Actions bar */}
1639
+ <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1640
+ <div className="relative flex-1">
1641
+ <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
1642
+ <Input
1643
+ placeholder={t('students.searchPlaceholder')}
1644
+ value={alunoSearch}
1645
+ onChange={(e) => setAlunoSearch(e.target.value)}
1646
+ className="pl-9"
1647
+ />
1648
+ </div>
1649
+ <div className="flex items-center gap-2">
1650
+ <Popover
1651
+ open={studentPickerOpen}
1652
+ onOpenChange={(open) => {
1653
+ setStudentPickerOpen(open);
1654
+ if (!open) setPessoaSearch('');
1655
+ }}
1656
+ >
1657
+ <PopoverTrigger asChild>
1658
+ <Button
1659
+ type="button"
1660
+ variant="outline"
1661
+ className="gap-2"
1662
+ >
1663
+ <UserPlus className="size-4" />
1664
+ {t('students.actions.addStudent')}
1665
+ {searchingPessoas && (
1666
+ <Loader2 className="size-4 animate-spin" />
1667
+ )}
1668
+ </Button>
1669
+ </PopoverTrigger>
1670
+ <PopoverContent className="w-80 p-0" align="end">
1671
+ <Command shouldFilter={false}>
1672
+ <CommandInput
1673
+ placeholder={t(
1674
+ 'dialogs.addStudents.searchPlaceholder'
1675
+ )}
1676
+ value={pessoaSearch}
1677
+ onValueChange={setPessoaSearch}
1678
+ />
1679
+ <CommandList>
1680
+ <CommandEmpty>
1681
+ <div className="px-3 py-4 text-sm text-muted-foreground">
1682
+ {t('dialogs.addStudents.notFound')}
1683
+ </div>
1684
+ </CommandEmpty>
1685
+ <CommandGroup>
1686
+ {(pessoaSearch
1687
+ ? pessoasElegiveisParaMatricula
1688
+ : studentPickerResults.filter((person) => {
1689
+ const isInstructor = Boolean(
1690
+ person.isInstructor
1691
+ );
1692
+ const canTeachCourses = Boolean(
1693
+ person.canTeachCourses
1694
+ );
1695
+ const isStudentByEnrollment = Boolean(
1696
+ person.isStudentByEnrollment
1697
+ );
1698
+ return !(
1699
+ isInstructor &&
1700
+ canTeachCourses &&
1701
+ !isStudentByEnrollment
1702
+ );
1703
+ })
1704
+ ).map((pessoa) => {
1705
+ const isSelected = selectedPessoas.includes(
1706
+ pessoa.id
1707
+ );
1708
+ return (
1709
+ <CommandItem
1710
+ key={pessoa.id}
1711
+ value={`${pessoa.nome}-${pessoa.id}`}
1712
+ onSelect={() => {
1713
+ setSelectedPessoas((prev) =>
1714
+ prev.includes(pessoa.id)
1715
+ ? prev.filter(
1716
+ (pid) => pid !== pessoa.id
1717
+ )
1718
+ : [...prev, pessoa.id]
1719
+ );
1720
+ }}
1721
+ >
1722
+ <div className="flex w-full items-center gap-3">
1723
+ <Checkbox checked={isSelected} />
1724
+ <Avatar className="size-8">
1725
+ <AvatarFallback className="text-[10px]">
1726
+ {pessoa.nome
1727
+ .split(' ')
1728
+ .map((n: string) => n[0])
1729
+ .join('')
1730
+ .slice(0, 2)}
1731
+ </AvatarFallback>
1732
+ </Avatar>
1733
+ <div className="min-w-0 flex-1">
1734
+ <p className="truncate text-sm font-medium">
1735
+ {pessoa.nome}
1736
+ </p>
1737
+ <p className="truncate text-xs text-muted-foreground">
1738
+ {pessoa.email}
1739
+ </p>
1740
+ </div>
1741
+ {isSelected && (
1742
+ <Check className="size-4 shrink-0 text-primary" />
1743
+ )}
1744
+ </div>
1745
+ </CommandItem>
1746
+ );
1747
+ })}
1748
+ </CommandGroup>
1749
+ </CommandList>
1750
+ </Command>
1751
+ </PopoverContent>
1752
+ </Popover>
1753
+ <Button
1754
+ type="button"
1755
+ variant="outline"
1756
+ size="icon"
1757
+ className="h-9 w-9 shrink-0 cursor-pointer"
1758
+ onClick={openCreateStudentDialog}
1759
+ aria-label={t('dialogs.addStudents.createNew')}
1760
+ >
1761
+ <Plus className="size-4" />
1762
+ </Button>
1763
+ </div>
1764
+ </div>
1765
+
1766
+ {/* Pending enrollment banner */}
1767
+ {selectedPessoas.length > 0 && (
1768
+ <div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-2.5 text-sm">
1769
+ <span className="font-medium">
1770
+ {t('dialogs.addStudents.confirm')} (
1771
+ {selectedPessoas.length})
1772
+ </span>
1773
+ <Button
1774
+ size="sm"
1775
+ className="gap-2"
1776
+ onClick={handleAddAlunos}
1777
+ disabled={savingStudents}
1778
+ >
1779
+ {savingStudents && (
1780
+ <Loader2 className="size-4 animate-spin" />
1781
+ )}
1782
+ <UserPlus className="size-4" />
1783
+ {t('students.actions.addStudent')}
1784
+ </Button>
1785
+ <Button
1786
+ variant="ghost"
1787
+ size="sm"
1788
+ onClick={() => {
1789
+ setSelectedPessoas([]);
1790
+ setPessoaSearch('');
1791
+ setStudentPickerOpen(false);
1792
+ setStudentPickerResults([]);
1793
+ }}
1794
+ >
1795
+ {t('students.actions.clearSelection')}
1796
+ </Button>
1797
+ </div>
1798
+ )}
1799
+
1800
+ {/* Bulk selection banner */}
1801
+ {selectedAlunos.length > 0 && (
1802
+ <div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm dark:border-amber-800 dark:bg-amber-950/30">
1803
+ <Checkbox
1804
+ checked={
1805
+ selectedAlunos.length === filteredAlunos.length
1806
+ }
1807
+ onCheckedChange={(checked) =>
1808
+ setSelectedAlunos(
1809
+ checked ? filteredAlunos.map((a) => a.id) : []
1810
+ )
1811
+ }
1812
+ />
1813
+ <span className="font-medium">
1814
+ {t('students.selectedCount', {
1815
+ count: selectedAlunos.length,
1816
+ })}
1817
+ </span>
1818
+ <Button
1819
+ variant="destructive"
1820
+ size="sm"
1821
+ className="ml-auto gap-2"
1822
+ onClick={handleRemoveSelectedAlunos}
1823
+ >
1824
+ <UserMinus className="size-4" />
1825
+ {t('students.actions.removeSelected', {
1826
+ count: selectedAlunos.length,
1827
+ })}
1828
+ </Button>
1829
+ <Button
1830
+ variant="ghost"
1831
+ size="sm"
1832
+ onClick={() => setSelectedAlunos([])}
1833
+ >
1834
+ {t('students.actions.clearSelection')}
1835
+ </Button>
1836
+ </div>
1837
+ )}
1838
+
1839
+ {/* Student list */}
1840
+ {loading ? (
1841
+ <div className="space-y-2">
1842
+ {Array.from({ length: 5 }).map((_, i) => (
1843
+ <div
1844
+ key={i}
1845
+ className="flex items-center gap-3 rounded-lg border border-border/70 p-3"
1846
+ >
1847
+ <Skeleton className="h-9 w-9 rounded-full" />
1848
+ <div className="flex-1 space-y-1">
1849
+ <Skeleton className="h-4 w-36" />
1850
+ <Skeleton className="h-3 w-48" />
1851
+ </div>
1852
+ <Skeleton className="h-4 w-12" />
1853
+ </div>
1854
+ ))}
1855
+ </div>
1856
+ ) : filteredAlunos.length === 0 ? (
1857
+ <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
1858
+ <div className="flex flex-col items-center gap-3">
1859
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/60 text-muted-foreground">
1860
+ <Users className="size-5" />
1861
+ </div>
661
1862
  <div>
662
- <p className="text-sm text-muted-foreground">
663
- {kpi.label}
664
- </p>
665
- <p className="mt-1 text-3xl font-bold tracking-tight">
666
- {kpi.valor}
1863
+ <p className="font-semibold text-sm">
1864
+ {alunoSearch
1865
+ ? t('students.empty.notFound')
1866
+ : t('students.empty.notEnrolled')}
667
1867
  </p>
668
1868
  <p className="mt-0.5 text-xs text-muted-foreground">
669
- {kpi.sub}
1869
+ {alunoSearch
1870
+ ? t('students.empty.notFoundDescription')
1871
+ : t('students.empty.notEnrolledDescription')}
670
1872
  </p>
671
1873
  </div>
672
- <div
673
- className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
674
- >
675
- <kpi.icon className={`size-5 ${kpi.iconColor}`} />
1874
+ {alunoSearch ? (
1875
+ <Button
1876
+ size="sm"
1877
+ variant="outline"
1878
+ onClick={() => setAlunoSearch('')}
1879
+ >
1880
+ {t('students.empty.clearSearch')}
1881
+ </Button>
1882
+ ) : (
1883
+ <div className="flex flex-wrap justify-center gap-2">
1884
+ <Button
1885
+ size="sm"
1886
+ variant="outline"
1887
+ className="gap-2"
1888
+ onClick={() => setStudentPickerOpen(true)}
1889
+ >
1890
+ <Search className="size-3.5" />
1891
+ {t('students.actions.addStudent')}
1892
+ </Button>
1893
+ <Button
1894
+ size="sm"
1895
+ className="gap-2"
1896
+ onClick={openCreateStudentDialog}
1897
+ >
1898
+ <Plus className="size-3.5" />
1899
+ {t('dialogs.addStudents.createNew')}
1900
+ </Button>
1901
+ </div>
1902
+ )}
1903
+ </div>
1904
+ </div>
1905
+ ) : (
1906
+ <div className="space-y-1.5">
1907
+ {filteredAlunos.map((aluno) => {
1908
+ const isSelected = selectedAlunos.includes(aluno.id);
1909
+ return (
1910
+ <div
1911
+ key={aluno.id}
1912
+ className={cn(
1913
+ 'group flex cursor-pointer items-center gap-3 rounded-lg border border-border/70 px-3 py-2.5 transition-colors hover:bg-muted/50',
1914
+ isSelected && 'border-primary/40 bg-primary/5'
1915
+ )}
1916
+ onClick={(e) => toggleSelectAluno(aluno.id, e)}
1917
+ >
1918
+ <Checkbox
1919
+ checked={isSelected}
1920
+ onClick={(e) => e.stopPropagation()}
1921
+ onCheckedChange={() =>
1922
+ toggleSelectAluno(aluno.id)
1923
+ }
1924
+ className="shrink-0"
1925
+ />
1926
+ <Avatar className="size-8 shrink-0">
1927
+ <AvatarFallback className="bg-gradient-to-br from-blue-100 to-blue-200 text-[11px] font-medium text-blue-700">
1928
+ {getPersonInitials(aluno.nome)}
1929
+ </AvatarFallback>
1930
+ </Avatar>
1931
+ <div className="min-w-0 flex-1">
1932
+ <p className="truncate text-sm font-medium">
1933
+ {aluno.nome}
1934
+ </p>
1935
+ <p className="truncate text-xs text-muted-foreground">
1936
+ {aluno.email}
1937
+ </p>
1938
+ </div>
1939
+ <div className="hidden shrink-0 items-center gap-3 sm:flex">
1940
+ <div className="w-16 text-right">
1941
+ <p className="text-[10px] text-muted-foreground">
1942
+ {aluno.progresso}%
1943
+ </p>
1944
+ <div className="mt-0.5 h-1 w-full overflow-hidden rounded-full bg-muted">
1945
+ <div
1946
+ className="h-full rounded-full bg-emerald-500"
1947
+ style={{ width: `${aluno.progresso}%` }}
1948
+ />
1949
+ </div>
1950
+ </div>
1951
+ <Badge variant="outline" className="text-[10px]">
1952
+ {aluno.status}
1953
+ </Badge>
1954
+ </div>
1955
+ <DropdownMenu>
1956
+ <DropdownMenuTrigger asChild>
1957
+ <Button
1958
+ variant="ghost"
1959
+ size="icon"
1960
+ className="size-8 shrink-0 opacity-0 group-hover:opacity-100"
1961
+ onClick={(e) => e.stopPropagation()}
1962
+ >
1963
+ <MoreHorizontal className="size-4" />
1964
+ </Button>
1965
+ </DropdownMenuTrigger>
1966
+ <DropdownMenuContent align="end">
1967
+ <DropdownMenuItem
1968
+ onClick={(e) => {
1969
+ e.stopPropagation();
1970
+ void openStudentProfile(aluno.id);
1971
+ }}
1972
+ >
1973
+ <Eye className="mr-2 size-4" />
1974
+ {t('students.menu.viewProfile')}
1975
+ </DropdownMenuItem>
1976
+ <DropdownMenuSeparator />
1977
+ <DropdownMenuItem
1978
+ className="text-destructive"
1979
+ onClick={(e) => {
1980
+ e.stopPropagation();
1981
+ setAlunoToRemove(aluno);
1982
+ setRemoveAlunoDialogOpen(true);
1983
+ }}
1984
+ >
1985
+ <UserMinus className="mr-2 size-4" />
1986
+ {t('students.menu.removeFromClass')}
1987
+ </DropdownMenuItem>
1988
+ </DropdownMenuContent>
1989
+ </DropdownMenu>
1990
+ </div>
1991
+ );
1992
+ })}
1993
+ </div>
1994
+ )}
1995
+ </TabsContent>
1996
+
1997
+ {/* ── Tab Calendario ──────────────────────────────────────────── */}
1998
+ <TabsContent value="calendario" className="mt-0">
1999
+ {/* ── View mode toolbar ─────────────────────────────────────── */}
2000
+ <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
2001
+ <div className="flex items-center rounded-lg border border-border/60 bg-muted/40 p-0.5">
2002
+ {(['single', 'quarter', 'year', 'list'] as const).map(
2003
+ (mode) => (
2004
+ <button
2005
+ key={mode}
2006
+ type="button"
2007
+ onClick={() => setCalendarViewMode(mode)}
2008
+ className={cn(
2009
+ 'rounded-md px-3 py-1 text-xs font-medium transition-colors',
2010
+ calendarViewMode === mode
2011
+ ? 'bg-background text-foreground shadow-sm'
2012
+ : 'text-muted-foreground hover:text-foreground'
2013
+ )}
2014
+ >
2015
+ {mode === 'single'
2016
+ ? t('calendar.viewSingle')
2017
+ : mode === 'quarter'
2018
+ ? t('calendar.viewQuarter')
2019
+ : mode === 'year'
2020
+ ? t('calendar.viewYear')
2021
+ : 'Lista'}
2022
+ </button>
2023
+ )
2024
+ )}
2025
+ </div>
2026
+ {calendarViewMode !== 'single' &&
2027
+ calendarViewMode !== 'list' && (
2028
+ <div className="flex items-center gap-1">
2029
+ <Button
2030
+ variant="ghost"
2031
+ size="icon"
2032
+ className="size-7"
2033
+ onClick={() =>
2034
+ setCalendarViewDate((d) =>
2035
+ addMonths(
2036
+ d,
2037
+ calendarViewMode === 'year' ? -12 : -3
2038
+ )
2039
+ )
2040
+ }
2041
+ >
2042
+ <ChevronLeft className="size-3.5" />
2043
+ </Button>
2044
+ <span className="min-w-[130px] text-center text-sm font-medium">
2045
+ {calendarViewMode === 'year'
2046
+ ? format(calendarViewDate, 'yyyy')
2047
+ : `${format(calendarViewDate, 'MMM', { locale: dateLocale })} – ${format(addMonths(calendarViewDate, 2), 'MMM yyyy', { locale: dateLocale })}`}
2048
+ </span>
2049
+ <Button
2050
+ variant="ghost"
2051
+ size="icon"
2052
+ className="size-7"
2053
+ onClick={() =>
2054
+ setCalendarViewDate((d) =>
2055
+ addMonths(
2056
+ d,
2057
+ calendarViewMode === 'year' ? 12 : 3
2058
+ )
2059
+ )
2060
+ }
2061
+ >
2062
+ <ChevronRight className="size-3.5" />
2063
+ </Button>
676
2064
  </div>
677
- </CardContent>
678
- </Card>
679
- </motion.div>
680
- ))}
681
- </motion.div>
2065
+ )}
2066
+ </div>
2067
+
2068
+ {calendarViewMode === 'single' ? (
2069
+ <LmsClassCalendar
2070
+ events={calendarEvents}
2071
+ classId={id}
2072
+ alunos={alunos}
2073
+ locale={locale}
2074
+ defaultStartTime={turma?.startTime}
2075
+ defaultEndTime={turma?.endTime}
2076
+ helperText={t('calendar.helper')}
2077
+ newLessonLabel={t('actions.newLesson')}
2078
+ calendarMessages={calendarMessages}
2079
+ mobileCalendarLabels={{
2080
+ previousLabel: t('calendar.messages.previous'),
2081
+ nextLabel: t('calendar.messages.next'),
2082
+ noEventsLabel: t('calendar.messages.noEventsInRange'),
2083
+ }}
2084
+ onNewLesson={() => openAulaSheet()}
2085
+ onOpenAulaSheet={openAulaSheet}
2086
+ onSessionSaved={handleQuickCreateSaved}
2087
+ />
2088
+ ) : calendarViewMode === 'list' ? (
2089
+ aulasState.length === 0 ? (
2090
+ <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
2091
+ <div className="flex flex-col items-center gap-3">
2092
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/60 text-muted-foreground">
2093
+ <CalendarIcon className="size-5" />
2094
+ </div>
2095
+ <div>
2096
+ <p className="text-sm font-semibold">
2097
+ {t('calendar.empty.title')}
2098
+ </p>
2099
+ <p className="mt-0.5 text-xs text-muted-foreground">
2100
+ {t('calendar.empty.description')}
2101
+ </p>
2102
+ </div>
2103
+ <Button
2104
+ size="sm"
2105
+ variant="outline"
2106
+ className="gap-2"
2107
+ onClick={() => openAulaSheet()}
2108
+ >
2109
+ <Plus className="size-3.5" />
2110
+ {t('actions.newLesson')}
2111
+ </Button>
2112
+ </div>
2113
+ </div>
2114
+ ) : (
2115
+ <div className="rounded-xl border border-border/60 overflow-hidden">
2116
+ <Table>
2117
+ <TableHeader>
2118
+ <TableRow className="bg-muted/40 hover:bg-muted/40">
2119
+ <TableHead className="w-[110px]">Data</TableHead>
2120
+ <TableHead className="w-[110px]">
2121
+ Horário
2122
+ </TableHead>
2123
+ <TableHead>Título</TableHead>
2124
+ <TableHead className="w-[100px]">Tipo</TableHead>
2125
+ <TableHead>Local / Link</TableHead>
2126
+ <TableHead>Instrutor</TableHead>
2127
+ <TableHead className="w-[60px]" />
2128
+ </TableRow>
2129
+ </TableHeader>
2130
+ <TableBody>
2131
+ {aulasState.map((aula) => {
2132
+ const sessionColor =
2133
+ aula.cor || aula.color || '#3b82f6';
2134
+ const isPast = getSessionEndDate(aula) < now;
2135
+ return (
2136
+ <TableRow
2137
+ key={aula.id}
2138
+ className="cursor-pointer hover:bg-muted/30"
2139
+ onClick={() => openAulaSheet(aula)}
2140
+ >
2141
+ <TableCell>
2142
+ <div className="flex items-center gap-2">
2143
+ <span
2144
+ className="inline-block h-2 w-2 shrink-0 rounded-full"
2145
+ style={{
2146
+ backgroundColor: sessionColor,
2147
+ }}
2148
+ />
2149
+ <span
2150
+ className={cn(
2151
+ 'text-sm tabular-nums',
2152
+ isPast && 'text-muted-foreground'
2153
+ )}
2154
+ >
2155
+ {format(
2156
+ parseSessionDate(aula.data),
2157
+ 'dd/MM/yyyy',
2158
+ { locale: dateLocale }
2159
+ )}
2160
+ </span>
2161
+ </div>
2162
+ </TableCell>
2163
+ <TableCell className="tabular-nums text-sm text-muted-foreground">
2164
+ {aula.horaInicio} – {aula.horaFim}
2165
+ </TableCell>
2166
+ <TableCell>
2167
+ <span className="text-sm font-medium">
2168
+ {aula.titulo}
2169
+ </span>
2170
+ </TableCell>
2171
+ <TableCell>
2172
+ <Badge
2173
+ variant="outline"
2174
+ className="gap-1 text-[11px]"
2175
+ >
2176
+ {aula.tipo === 'online' ? (
2177
+ <Video className="size-3" />
2178
+ ) : (
2179
+ <MapPin className="size-3" />
2180
+ )}
2181
+ {tClasses(`type.${aula.tipo}`)}
2182
+ </Badge>
2183
+ </TableCell>
2184
+ <TableCell className="max-w-[180px] truncate text-sm text-muted-foreground">
2185
+ {aula.meetingUrl || aula.local || '—'}
2186
+ </TableCell>
2187
+ <TableCell className="text-sm text-muted-foreground">
2188
+ {aula.instructorName || '—'}
2189
+ </TableCell>
2190
+ <TableCell>
2191
+ <Button
2192
+ variant="ghost"
2193
+ size="icon"
2194
+ className="size-7"
2195
+ onClick={(e) => {
2196
+ e.stopPropagation();
2197
+ openAulaSheet(aula);
2198
+ }}
2199
+ >
2200
+ <Pencil className="size-3.5" />
2201
+ </Button>
2202
+ </TableCell>
2203
+ </TableRow>
2204
+ );
2205
+ })}
2206
+ </TableBody>
2207
+ </Table>
2208
+ </div>
2209
+ )
2210
+ ) : (
2211
+ <div
2212
+ className={cn(
2213
+ 'grid gap-3',
2214
+ calendarViewMode === 'year'
2215
+ ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
2216
+ : 'grid-cols-1 sm:grid-cols-3'
2217
+ )}
2218
+ >
2219
+ {Array.from(
2220
+ { length: calendarViewMode === 'year' ? 12 : 3 },
2221
+ (_, monthIdx) => {
2222
+ const monthBase =
2223
+ calendarViewMode === 'year'
2224
+ ? new Date(calendarViewDate.getFullYear(), 0, 1)
2225
+ : startOfMonth(calendarViewDate);
2226
+ const month = addMonths(monthBase, monthIdx);
2227
+ const mStart = startOfMonth(month);
2228
+ const mEnd = endOfMonth(month);
2229
+ const calStart = startOfWeek(mStart, {
2230
+ weekStartsOn: 1,
2231
+ });
2232
+ const calEnd = endOfWeek(mEnd, { weekStartsOn: 1 });
2233
+ const days = eachDayOfInterval({
2234
+ start: calStart,
2235
+ end: calEnd,
2236
+ });
2237
+ const today = new Date();
2238
+ // Jan 1 2024 was a Monday — use as stable reference for weekday single letters
2239
+ const weekLetters = Array.from(
2240
+ { length: 7 },
2241
+ (_, wi) => {
2242
+ const ref = new Date(2024, 0, 1 + wi);
2243
+ return format(ref, 'EEEEE', {
2244
+ locale: dateLocale,
2245
+ });
2246
+ }
2247
+ );
682
2248
 
683
- {/* Info Card */}
684
- <motion.div variants={fadeUp} className="mb-6">
685
- <Card>
686
- <CardContent className="p-5">
687
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
688
- <div className="flex items-center gap-3">
689
- <div className="flex size-10 items-center justify-center rounded-lg bg-amber-100">
690
- <CalendarIcon className="size-5 text-amber-600" />
2249
+ return (
2250
+ <div
2251
+ key={format(month, 'yyyy-MM')}
2252
+ className="rounded-xl border border-border/60 bg-muted/20 p-3"
2253
+ >
2254
+ <p className="mb-2 text-center text-[11px] font-semibold capitalize text-foreground">
2255
+ {format(month, 'MMMM yyyy', {
2256
+ locale: dateLocale,
2257
+ })}
2258
+ </p>
2259
+ <div className="grid grid-cols-7 gap-0.5">
2260
+ {weekLetters.map((d, di) => (
2261
+ <div
2262
+ key={di}
2263
+ className="pb-1 text-center text-[9px] font-medium uppercase text-muted-foreground"
2264
+ >
2265
+ {d}
2266
+ </div>
2267
+ ))}
2268
+ {days.map((day) => {
2269
+ const dayKey = format(day, 'yyyy-MM-dd');
2270
+ const daySessions =
2271
+ sessionsByDay.get(dayKey) ?? [];
2272
+ const isCurrentMonth = isSameMonth(
2273
+ day,
2274
+ month
2275
+ );
2276
+ const isToday = isSameDay(day, today);
2277
+ const hasSessions =
2278
+ daySessions.length > 0 && isCurrentMonth;
2279
+
2280
+ return (
2281
+ <button
2282
+ key={dayKey}
2283
+ type="button"
2284
+ className={cn(
2285
+ 'flex flex-col items-center rounded-md py-0.5 transition-colors',
2286
+ isCurrentMonth
2287
+ ? 'text-foreground'
2288
+ : 'text-muted-foreground/20',
2289
+ hasSessions
2290
+ ? 'cursor-pointer hover:bg-muted/60'
2291
+ : 'cursor-default'
2292
+ )}
2293
+ onClick={() => {
2294
+ if (!hasSessions) return;
2295
+ if (daySessions.length === 1) {
2296
+ openAulaSheet(daySessions[0]);
2297
+ } else {
2298
+ setCalendarViewMode('single');
2299
+ }
2300
+ }}
2301
+ title={
2302
+ hasSessions
2303
+ ? daySessions
2304
+ .map((a) => a.titulo)
2305
+ .join(', ')
2306
+ : undefined
2307
+ }
2308
+ >
2309
+ <span
2310
+ className={cn(
2311
+ 'flex h-5 w-5 items-center justify-center rounded-full text-[10px]',
2312
+ isToday &&
2313
+ 'bg-primary font-bold text-primary-foreground'
2314
+ )}
2315
+ >
2316
+ {format(day, 'd')}
2317
+ </span>
2318
+ <div className="flex h-1.5 items-center justify-center gap-0.5">
2319
+ {hasSessions &&
2320
+ daySessions
2321
+ .slice(0, 3)
2322
+ .map((aula, ai) => (
2323
+ <span
2324
+ key={ai}
2325
+ className="h-1 w-1 rounded-full"
2326
+ style={{
2327
+ backgroundColor:
2328
+ aula.cor ||
2329
+ aula.color ||
2330
+ '#3b82f6',
2331
+ }}
2332
+ />
2333
+ ))}
2334
+ </div>
2335
+ </button>
2336
+ );
2337
+ })}
2338
+ </div>
2339
+ </div>
2340
+ );
2341
+ }
2342
+ )}
691
2343
  </div>
692
- <div>
693
- <p className="text-xs text-muted-foreground">
694
- {t('info.period')}
695
- </p>
696
- <p className="text-sm font-medium">
697
- {format(new Date(turma.dataInicio), 'dd/MM/yyyy')} -{' '}
698
- {format(new Date(turma.dataFim), 'dd/MM/yyyy')}
699
- </p>
2344
+ )}
2345
+ </TabsContent>
2346
+
2347
+ {/* ── Tab Presenca ────────────────────────────────────────────── */}
2348
+ <TabsContent value="presenca" className="mt-0">
2349
+ <p className="mb-4 text-sm text-muted-foreground">
2350
+ {t('attendance.helper')}
2351
+ </p>
2352
+ {loading ? (
2353
+ <div className="grid gap-3 sm:grid-cols-2">
2354
+ {Array.from({ length: 4 }).map((_, i) => (
2355
+ <Card
2356
+ key={i}
2357
+ className="overflow-hidden border-border/70"
2358
+ >
2359
+ <CardContent className="p-4">
2360
+ <Skeleton className="h-20" />
2361
+ </CardContent>
2362
+ </Card>
2363
+ ))}
700
2364
  </div>
701
- </div>
702
- <div className="flex items-center gap-3">
703
- <div className="flex size-10 items-center justify-center rounded-lg bg-purple-100">
704
- <Clock className="size-5 text-purple-600" />
2365
+ ) : aulasState.length === 0 ? (
2366
+ <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
2367
+ <div className="flex flex-col items-center gap-3">
2368
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/60 text-muted-foreground">
2369
+ <CheckCircle2 className="size-5" />
2370
+ </div>
2371
+ <div>
2372
+ <p className="font-semibold text-sm">
2373
+ {t('attendance.empty.title')}
2374
+ </p>
2375
+ <p className="mt-0.5 text-xs text-muted-foreground">
2376
+ {t('attendance.empty.description')}
2377
+ </p>
2378
+ </div>
2379
+ <Button
2380
+ size="sm"
2381
+ variant="outline"
2382
+ className="gap-2"
2383
+ onClick={() => {
2384
+ openAulaSheet();
2385
+ setActiveTab('calendario');
2386
+ }}
2387
+ >
2388
+ <Plus className="size-3.5" />
2389
+ {t('actions.newLesson')}
2390
+ </Button>
2391
+ </div>
705
2392
  </div>
706
- <div>
707
- <p className="text-xs text-muted-foreground">
708
- {t('info.schedule')}
709
- </p>
710
- <p className="text-sm font-medium">{turma.horario}</p>
2393
+ ) : (
2394
+ <div className="grid gap-3 sm:grid-cols-2">
2395
+ {aulasState
2396
+ .slice(-12)
2397
+ .reverse()
2398
+ .map((aula) => {
2399
+ const sessionColor =
2400
+ aula.cor || aula.color || '#3b82f6';
2401
+ const isPast = getSessionEndDate(aula) < now;
2402
+ return (
2403
+ <Card
2404
+ key={aula.id}
2405
+ className="cursor-pointer overflow-hidden border-border/60 transition-all hover:-translate-y-0.5 hover:shadow-md"
2406
+ onClick={() => openPresenca(aula)}
2407
+ >
2408
+ <div className="flex">
2409
+ <div
2410
+ className="w-1 shrink-0"
2411
+ style={{ backgroundColor: sessionColor }}
2412
+ />
2413
+ <CardContent className="flex-1 p-3.5">
2414
+ <div className="flex items-start justify-between gap-2">
2415
+ <div className="min-w-0">
2416
+ <h4 className="truncate text-sm font-semibold">
2417
+ {aula.titulo}
2418
+ </h4>
2419
+ <p className="text-xs capitalize text-muted-foreground">
2420
+ {format(
2421
+ parseSessionDate(aula.data),
2422
+ 'EEE, dd/MM',
2423
+ { locale: dateLocale }
2424
+ )}
2425
+ {' · '}
2426
+ {aula.horaInicio} – {aula.horaFim}
2427
+ </p>
2428
+ </div>
2429
+ <Badge
2430
+ variant="outline"
2431
+ className={cn(
2432
+ 'shrink-0 text-[10px]',
2433
+ isPast
2434
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
2435
+ : ''
2436
+ )}
2437
+ >
2438
+ {isPast ? (
2439
+ <CheckCircle2 className="mr-1 size-3" />
2440
+ ) : aula.tipo === 'online' ? (
2441
+ <Video className="mr-1 size-3" />
2442
+ ) : (
2443
+ <MapPin className="mr-1 size-3" />
2444
+ )}
2445
+ {isPast
2446
+ ? t('attendance.register')
2447
+ : tClasses(`type.${aula.tipo}`)}
2448
+ </Badge>
2449
+ </div>
2450
+ </CardContent>
2451
+ </div>
2452
+ </Card>
2453
+ );
2454
+ })}
711
2455
  </div>
2456
+ )}
2457
+ </TabsContent>
2458
+ </Tabs>
2459
+ </div>
2460
+
2461
+ {/* ── Right: Operational Sidebar ──────────────────────────────── */}
2462
+ <div className="space-y-3 lg:sticky lg:top-4 lg:self-start">
2463
+ {/* Next Session */}
2464
+ <OperationalSidebarCard
2465
+ title={t('sidebar.nextSession') ?? 'Próxima Aula'}
2466
+ >
2467
+ {loading ? (
2468
+ <div className="space-y-2">
2469
+ <Skeleton className="h-4 w-32" />
2470
+ <Skeleton className="h-4 w-24" />
712
2471
  </div>
713
- <div className="flex items-center gap-3">
714
- <div className="flex size-10 items-center justify-center rounded-lg bg-emerald-100">
715
- {turma.tipo === 'online' ? (
716
- <Video className="size-5 text-emerald-600" />
717
- ) : (
718
- <MapPin className="size-5 text-emerald-600" />
719
- )}
720
- </div>
2472
+ ) : nextSession ? (
2473
+ <div className="space-y-2">
721
2474
  <div>
2475
+ <p className="text-sm font-semibold">
2476
+ {nextSession.titulo}
2477
+ </p>
722
2478
  <p className="text-xs text-muted-foreground">
723
- {turma.tipo === 'online'
724
- ? t('info.onlineLabel')
725
- : t('info.locationLabel')}
2479
+ {nextSessionStartsAt &&
2480
+ format(nextSessionStartsAt, "EEEE, dd 'de' MMM", {
2481
+ locale: dateLocale,
2482
+ })}
726
2483
  </p>
727
- {turma.tipo === 'online' ? (
2484
+ </div>
2485
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
2486
+ <Clock className="size-3.5 shrink-0" />
2487
+ <span>
2488
+ {nextSession.horaInicio} – {nextSession.horaFim}
2489
+ </span>
2490
+ </div>
2491
+ {nextSession.tipo === 'online' &&
2492
+ (nextSession.meetingUrl || nextSession.local) ? (
2493
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
2494
+ <Video className="size-3.5 shrink-0" />
728
2495
  <a
729
- href={turma.local}
2496
+ href={nextSession.meetingUrl || nextSession.local}
730
2497
  target="_blank"
731
2498
  rel="noopener noreferrer"
732
- className="text-sm font-medium text-blue-600 hover:underline"
2499
+ className="truncate text-blue-600 hover:underline"
733
2500
  >
734
2501
  {t('info.accessRoom')}
735
2502
  </a>
736
- ) : (
737
- <p className="text-sm font-medium">{turma.local}</p>
738
- )}
739
- </div>
740
- </div>
741
- <div className="flex items-center gap-3">
742
- <div className="flex size-10 items-center justify-center rounded-lg bg-blue-100">
743
- <Monitor className="size-5 text-blue-600" />
744
- </div>
745
- <div>
746
- <p className="text-xs text-muted-foreground">
747
- {t('info.modality')}
748
- </p>
749
- <p className="text-sm font-medium capitalize">
750
- {tClasses(`type.${turma.tipo}`)}
751
- </p>
752
- </div>
753
- </div>
754
- </div>
755
- </CardContent>
756
- </Card>
757
- </motion.div>
758
-
759
- {/* Tabs */}
760
- <motion.div variants={fadeUp}>
761
- <Tabs
762
- value={activeTab}
763
- onValueChange={setActiveTab}
764
- className="w-full"
765
- >
766
- <TabsList className="mb-4 w-full justify-start overflow-x-auto">
767
- <TabsTrigger value="alunos" className="gap-2">
768
- <Users className="size-4" />
769
- {t('tabs.students')}
770
- </TabsTrigger>
771
- <TabsTrigger value="calendario" className="gap-2">
772
- <CalendarIcon className="size-4" />
773
- {t('tabs.calendar')}
774
- </TabsTrigger>
775
- <TabsTrigger value="presenca" className="gap-2">
776
- <CheckCircle2 className="size-4" />
777
- {t('tabs.attendance')}
778
- </TabsTrigger>
779
- </TabsList>
780
-
781
- {/* ── Tab Alunos ────────────────────────────────────────────────── */}
782
- <TabsContent value="alunos" className="mt-0">
783
- {/* Actions bar */}
784
- <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
785
- <div className="relative flex-1 max-w-md">
786
- <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
787
- <Input
788
- placeholder={t('students.searchPlaceholder')}
789
- value={alunoSearch}
790
- onChange={(e) => setAlunoSearch(e.target.value)}
791
- className="pl-9"
792
- />
793
- </div>
794
- <div className="flex gap-2">
795
- {selectedAlunos.length > 0 && (
796
- <Button
797
- variant="destructive"
798
- size="sm"
799
- className="gap-2"
800
- onClick={handleRemoveSelectedAlunos}
801
- >
802
- <UserMinus className="size-4" />
803
- {t('students.actions.removeSelected', {
804
- count: selectedAlunos.length,
805
- })}
806
- </Button>
2503
+ </div>
2504
+ ) : nextSession.local ? (
2505
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
2506
+ <MapPin className="size-3.5 shrink-0" />
2507
+ <span className="truncate">{nextSession.local}</span>
2508
+ </div>
2509
+ ) : null}
2510
+ {nextSession.instructorName && (
2511
+ <div className="flex items-center gap-2 pt-1">
2512
+ <Avatar className="size-6">
2513
+ <AvatarFallback className="text-[9px]">
2514
+ {getPersonInitials(nextSession.instructorName)}
2515
+ </AvatarFallback>
2516
+ </Avatar>
2517
+ <span className="truncate text-xs text-muted-foreground">
2518
+ {nextSession.instructorName}
2519
+ </span>
2520
+ </div>
807
2521
  )}
808
2522
  <Button
809
2523
  size="sm"
810
- className="gap-2"
811
- onClick={() => setAddAlunoDialogOpen(true)}
2524
+ className="mt-1 w-full gap-2 text-xs"
2525
+ onClick={() => openAulaSheet(nextSession)}
812
2526
  >
813
- <UserPlus className="size-4" />
814
- {t('students.actions.addStudent')}
2527
+ <CalendarIcon className="size-3.5" />
2528
+ {t('sidebar.openSession') ?? 'Abrir sessão'}
815
2529
  </Button>
816
2530
  </div>
817
- </div>
818
-
819
- {/* Selection info */}
820
- {selectedAlunos.length > 0 && (
821
- <div className="mb-4 flex items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2 text-sm">
822
- <Checkbox
823
- checked={selectedAlunos.length === filteredAlunos.length}
824
- onCheckedChange={(checked) =>
825
- setSelectedAlunos(
826
- checked ? filteredAlunos.map((a) => a.id) : []
827
- )
828
- }
829
- />
830
- <span>
831
- {t('students.selectedCount', {
832
- count: selectedAlunos.length,
833
- })}
834
- </span>
2531
+ ) : (
2532
+ <div className="space-y-2">
2533
+ <p className="text-sm text-muted-foreground">
2534
+ {t('sidebar.noNextSession') ??
2535
+ 'Nenhuma aula futura agendada.'}
2536
+ </p>
835
2537
  <Button
836
- variant="ghost"
837
2538
  size="sm"
838
- onClick={() => setSelectedAlunos([])}
2539
+ variant="outline"
2540
+ className="w-full gap-2 text-xs"
2541
+ onClick={() => openAulaSheet()}
839
2542
  >
840
- {t('students.actions.clearSelection')}
2543
+ <Plus className="size-3.5" />
2544
+ {t('actions.newLesson')}
841
2545
  </Button>
842
2546
  </div>
843
2547
  )}
2548
+ </OperationalSidebarCard>
844
2549
 
845
- {/* Alunos grid */}
2550
+ {/* Instructor */}
2551
+ <OperationalSidebarCard
2552
+ title={t('sidebar.instructor') ?? 'Instrutor'}
2553
+ >
846
2554
  {loading ? (
847
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
848
- {Array.from({ length: 6 }).map((_, i) => (
849
- <Card key={i}>
850
- <CardContent className="p-4">
851
- <Skeleton className="mb-2 h-12 w-12 rounded-full" />
852
- <Skeleton className="h-4 w-32" />
853
- <Skeleton className="mt-1 h-3 w-24" />
854
- </CardContent>
855
- </Card>
856
- ))}
2555
+ <div className="flex items-center gap-2">
2556
+ <Skeleton className="size-8 rounded-full" />
2557
+ <Skeleton className="h-4 w-24" />
2558
+ </div>
2559
+ ) : primaryInstructor !== 'Nao definido' ? (
2560
+ <div className="flex items-center gap-2.5">
2561
+ <Avatar className="size-9">
2562
+ <AvatarFallback className="bg-primary/10 text-xs font-medium text-primary">
2563
+ {getPersonInitials(primaryInstructor)}
2564
+ </AvatarFallback>
2565
+ </Avatar>
2566
+ <div className="min-w-0 flex-1">
2567
+ <p className="truncate text-sm font-semibold">
2568
+ {primaryInstructor}
2569
+ </p>
2570
+ <p className="text-[11px] text-muted-foreground">
2571
+ {t('sidebar.instructorLabel') ?? 'Instrutor principal'}
2572
+ </p>
2573
+ </div>
857
2574
  </div>
858
- ) : filteredAlunos.length === 0 ? (
859
- <EmptyState
860
- icon={<Users className="h-12 w-12" />}
861
- title={
862
- alunoSearch
863
- ? t('students.empty.notFound')
864
- : t('students.empty.notEnrolled')
865
- }
866
- description={
867
- alunoSearch
868
- ? t('students.empty.notFoundDescription')
869
- : t('students.empty.notEnrolledDescription')
870
- }
871
- actionLabel={
872
- alunoSearch
873
- ? t('students.empty.clearSearch')
874
- : t('students.actions.addStudent')
875
- }
876
- onAction={() => {
877
- if (alunoSearch) {
878
- setAlunoSearch('');
879
- return;
880
- }
881
-
882
- setAddAlunoDialogOpen(true);
883
- }}
884
- />
885
2575
  ) : (
886
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
887
- {filteredAlunos.map((aluno) => {
888
- const isSelected = selectedAlunos.includes(aluno.id);
889
- return (
890
- <Card
891
- key={aluno.id}
892
- className={`group cursor-pointer transition-all hover:shadow-md ${isSelected ? 'ring-2 ring-primary' : ''}`}
893
- onClick={(e) => toggleSelectAluno(aluno.id, e)}
894
- >
895
- <CardContent className="p-4">
896
- <div className="flex items-start gap-3">
897
- <div className="relative">
898
- <Avatar className="size-12">
899
- <AvatarImage src={aluno.avatar} />
900
- <AvatarFallback className="bg-linear-to-br from-blue-100 to-blue-200 text-blue-700 font-medium">
901
- {aluno.nome
902
- .split(' ')
903
- .map((n) => n[0])
904
- .join('')
905
- .slice(0, 2)}
906
- </AvatarFallback>
907
- </Avatar>
908
- {isSelected && (
909
- <div className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
910
- <Check className="size-3" />
911
- </div>
912
- )}
913
- </div>
914
- <div className="flex-1 min-w-0">
915
- <div className="flex items-start justify-between gap-2">
916
- <div>
917
- <h4 className="font-semibold truncate">
918
- {aluno.nome}
919
- </h4>
920
- <p className="text-xs text-muted-foreground truncate">
921
- {aluno.email}
922
- </p>
923
- </div>
924
- <DropdownMenu>
925
- <DropdownMenuTrigger asChild>
926
- <Button
927
- variant="ghost"
928
- size="icon"
929
- className="size-8"
930
- onClick={(e) => e.stopPropagation()}
931
- >
932
- <MoreHorizontal className="size-4" />
933
- </Button>
934
- </DropdownMenuTrigger>
935
- <DropdownMenuContent align="end">
936
- <DropdownMenuItem>
937
- <Eye className="mr-2 size-4" />
938
- {t('students.menu.viewProfile')}
939
- </DropdownMenuItem>
940
- <DropdownMenuItem>
941
- <Mail className="mr-2 size-4" />
942
- {t('students.menu.sendEmail')}
943
- </DropdownMenuItem>
944
- <DropdownMenuSeparator />
945
- <DropdownMenuItem
946
- className="text-destructive"
947
- onClick={(e) => {
948
- e.stopPropagation();
949
- setAlunoToRemove(aluno);
950
- setRemoveAlunoDialogOpen(true);
951
- }}
952
- >
953
- <UserMinus className="mr-2 size-4" />
954
- {t('students.menu.removeFromClass')}
955
- </DropdownMenuItem>
956
- </DropdownMenuContent>
957
- </DropdownMenu>
958
- </div>
959
- </div>
960
- </div>
961
- <div className="mt-4 grid grid-cols-2 gap-2">
962
- <div className="rounded-lg bg-muted/50 p-2 text-center">
963
- <p className="text-lg font-bold text-blue-600">
964
- {aluno.progresso}%
965
- </p>
966
- <p className="text-[10px] text-muted-foreground">
967
- {t('students.progress')}
968
- </p>
969
- </div>
970
- <div className="rounded-lg bg-muted/50 p-2 text-center">
971
- <p
972
- className={`text-lg font-bold ${aluno.presenca >= 75 ? 'text-emerald-600' : aluno.presenca >= 50 ? 'text-amber-600' : 'text-red-600'}`}
973
- >
974
- {aluno.presenca}%
975
- </p>
976
- <p className="text-[10px] text-muted-foreground">
977
- {t('students.attendance')}
978
- </p>
979
- </div>
980
- </div>
981
- </CardContent>
982
- </Card>
983
- );
984
- })}
2576
+ <div className="flex items-center gap-2 text-muted-foreground">
2577
+ <div className="flex h-9 w-9 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/40">
2578
+ <Users className="size-4" />
2579
+ </div>
2580
+ <p className="text-xs">
2581
+ {t('sidebar.noInstructor') ?? 'Não definido'}
2582
+ </p>
985
2583
  </div>
986
2584
  )}
987
- </TabsContent>
2585
+ </OperationalSidebarCard>
988
2586
 
989
- {/* ── Tab Calendario ────────────────────────────────────────────── */}
990
- <TabsContent value="calendario" className="mt-0">
991
- <div className="mb-4 flex items-center justify-between">
992
- <p className="text-sm text-muted-foreground">
993
- {t('calendar.helper')}
994
- </p>
995
- <Button
996
- size="sm"
997
- className="gap-2"
998
- onClick={() => openAulaSheet()}
999
- >
1000
- <Plus className="size-4" />
1001
- {t('actions.newLesson')}
1002
- </Button>
1003
- </div>
1004
- <Card>
1005
- <CardContent className="p-4">
1006
- <div className="h-[600px]">
1007
- <Calendar
1008
- localizer={localizer}
1009
- events={calendarEvents}
1010
- startAccessor="start"
1011
- endAccessor="end"
1012
- view={calendarView}
1013
- onView={(v) => setCalendarView(v)}
1014
- date={calendarDate}
1015
- onNavigate={(d) => setCalendarDate(d)}
1016
- views={['month', 'week']}
1017
- messages={calendarMessages}
1018
- eventPropGetter={eventStyleGetter}
1019
- onSelectEvent={handleSelectEvent}
1020
- culture={calendarCulture}
1021
- popup
1022
- selectable
1023
- style={{ height: '100%' }}
1024
- />
2587
+ {/* Class Info */}
2588
+ <OperationalSidebarCard
2589
+ title={t('sidebar.classInfo') ?? 'Informações da Turma'}
2590
+ >
2591
+ <div className="space-y-2.5">
2592
+ {startDate && endDate && (
2593
+ <div className="flex items-start gap-2 text-xs">
2594
+ <CalendarIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
2595
+ <div className="min-w-0">
2596
+ <p className="font-medium text-[10px] uppercase tracking-wide text-muted-foreground">
2597
+ {t('info.period')}
2598
+ </p>
2599
+ <p className="font-medium">
2600
+ {format(new Date(startDate), 'dd/MM/yy')}
2601
+ {' – '}
2602
+ {format(new Date(endDate), 'dd/MM/yy')}
2603
+ </p>
2604
+ </div>
1025
2605
  </div>
1026
- </CardContent>
1027
- </Card>
1028
- </TabsContent>
1029
-
1030
- {/* ── Tab Presenca ──────────────────────────────────────────────── */}
1031
- <TabsContent value="presenca" className="mt-0">
1032
- <p className="mb-4 text-sm text-muted-foreground">
1033
- {t('attendance.helper')}
1034
- </p>
1035
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
1036
- {loading
1037
- ? Array.from({ length: 6 }).map((_, i) => (
1038
- <Card key={i}>
1039
- <CardContent className="p-4">
1040
- <Skeleton className="h-20" />
1041
- </CardContent>
1042
- </Card>
1043
- ))
1044
- : aulas
1045
- .filter((a) => a.data <= new Date())
1046
- .slice(-12)
1047
- .reverse()
1048
- .map((aula) => (
1049
- <Card
1050
- key={aula.id}
1051
- className="cursor-pointer transition-all hover:shadow-md hover:-translate-y-0.5"
1052
- onClick={() => openPresenca(aula)}
2606
+ )}
2607
+ {schedule !== '—' && (
2608
+ <div className="flex items-start gap-2 text-xs">
2609
+ <Clock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
2610
+ <div className="min-w-0">
2611
+ <p className="font-medium text-[10px] uppercase tracking-wide text-muted-foreground">
2612
+ {t('info.schedule')}
2613
+ </p>
2614
+ <p className="font-medium">{schedule}</p>
2615
+ </div>
2616
+ </div>
2617
+ )}
2618
+ <div className="flex items-start gap-2 text-xs">
2619
+ {isOnline ? (
2620
+ <Video className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
2621
+ ) : (
2622
+ <MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
2623
+ )}
2624
+ <div className="min-w-0 flex-1">
2625
+ <p className="font-medium text-[10px] uppercase tracking-wide text-muted-foreground">
2626
+ {isOnline
2627
+ ? t('info.onlineLabel')
2628
+ : t('info.locationLabel')}
2629
+ </p>
2630
+ {isOnline && roomUrl ? (
2631
+ <div className="flex items-center gap-1">
2632
+ <a
2633
+ href={roomUrl}
2634
+ target="_blank"
2635
+ rel="noopener noreferrer"
2636
+ className="truncate font-medium text-blue-600 hover:underline"
1053
2637
  >
1054
- <CardContent className="p-4">
1055
- <div className="flex items-start justify-between gap-2">
1056
- <div>
1057
- <h4 className="font-semibold">
1058
- {aula.titulo}
1059
- </h4>
1060
- <p className="text-xs text-muted-foreground">
1061
- {format(aula.data, 'EEEE, dd/MM', {
1062
- locale: dateLocale,
1063
- })}
1064
- </p>
1065
- </div>
1066
- <Badge
1067
- variant={
1068
- aula.tipo === 'online'
1069
- ? 'secondary'
1070
- : 'outline'
1071
- }
1072
- className="text-[10px]"
1073
- >
1074
- {aula.tipo === 'online' ? (
1075
- <Video className="mr-1 size-3" />
1076
- ) : (
1077
- <MapPin className="mr-1 size-3" />
1078
- )}
1079
- {tClasses(`type.${aula.tipo}`)}
1080
- </Badge>
1081
- </div>
1082
- <div className="mt-3 flex items-center justify-between text-sm">
1083
- <span className="text-muted-foreground">
1084
- {aula.horaInicio} - {aula.horaFim}
1085
- </span>
1086
- <Button
1087
- variant="ghost"
1088
- size="sm"
1089
- className="h-7 gap-1 text-xs"
1090
- >
1091
- <CheckCircle2 className="size-3" />
1092
- {t('attendance.register')}
1093
- </Button>
1094
- </div>
1095
- </CardContent>
1096
- </Card>
1097
- ))}
2638
+ {t('info.accessRoom')}
2639
+ </a>
2640
+ <Button
2641
+ variant="ghost"
2642
+ size="icon"
2643
+ className="size-5 shrink-0"
2644
+ onClick={() => {
2645
+ void navigator.clipboard.writeText(roomUrl);
2646
+ toast.success(
2647
+ t('sidebar.linkCopied') ?? 'Link copiado!'
2648
+ );
2649
+ }}
2650
+ aria-label="Copiar link"
2651
+ >
2652
+ <Save className="size-3" />
2653
+ </Button>
2654
+ </div>
2655
+ ) : (
2656
+ <p className="font-medium">{location}</p>
2657
+ )}
2658
+ </div>
2659
+ </div>
2660
+ <div className="flex items-start gap-2 text-xs">
2661
+ <Monitor className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
2662
+ <div className="min-w-0">
2663
+ <p className="font-medium text-[10px] uppercase tracking-wide text-muted-foreground">
2664
+ {t('info.modality')}
2665
+ </p>
2666
+ <p className="font-medium capitalize">
2667
+ {tClasses(`type.${deliveryTypeKey}`)}
2668
+ </p>
2669
+ </div>
2670
+ </div>
1098
2671
  </div>
1099
- </TabsContent>
1100
- </Tabs>
2672
+ </OperationalSidebarCard>
2673
+
2674
+ {/* Occupancy */}
2675
+ {capacity > 0 && (
2676
+ <OperationalSidebarCard
2677
+ title={t('sidebar.occupancy') ?? 'Ocupação'}
2678
+ >
2679
+ <div>
2680
+ <div className="mb-2 flex items-baseline justify-between">
2681
+ <div className="flex items-baseline gap-1">
2682
+ <span className="text-2xl font-bold tabular-nums">
2683
+ {occupiedSeats}
2684
+ </span>
2685
+ <span className="text-sm text-muted-foreground">
2686
+ / {capacity}
2687
+ </span>
2688
+ </div>
2689
+ <span
2690
+ className={cn(
2691
+ 'text-sm font-semibold tabular-nums',
2692
+ occupancyRate >= 90
2693
+ ? 'text-red-600'
2694
+ : occupancyRate >= 70
2695
+ ? 'text-amber-600'
2696
+ : 'text-emerald-600'
2697
+ )}
2698
+ >
2699
+ {occupancyRate}%
2700
+ </span>
2701
+ </div>
2702
+ <div className="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
2703
+ <div
2704
+ className={cn(
2705
+ 'h-full rounded-full transition-all',
2706
+ occupancyRate >= 90
2707
+ ? 'bg-red-500'
2708
+ : occupancyRate >= 70
2709
+ ? 'bg-amber-500'
2710
+ : 'bg-emerald-500'
2711
+ )}
2712
+ style={{ width: `${occupancyRate}%` }}
2713
+ />
2714
+ </div>
2715
+ <p className="text-xs text-muted-foreground">
2716
+ {availableSeats > 0
2717
+ ? `${availableSeats} ${t('sidebar.seatsAvailable') ?? 'vagas disponíveis'}`
2718
+ : (t('sidebar.classFull') ?? 'Turma lotada')}
2719
+ </p>
2720
+ <Button
2721
+ size="sm"
2722
+ variant="outline"
2723
+ className="mt-2 w-full gap-2 text-xs"
2724
+ onClick={() => setActiveTab('alunos')}
2725
+ >
2726
+ <Users className="size-3.5" />
2727
+ {t('sidebar.viewStudents') ?? 'Ver alunos'}
2728
+ </Button>
2729
+ </div>
2730
+ </OperationalSidebarCard>
2731
+ )}
2732
+ </div>
1101
2733
  </motion.div>
1102
2734
  </motion.div>
1103
2735
  </div>
1104
2736
 
1105
- {/* ── Dialog Adicionar Alunos ──────────────────────────────────────────── */}
1106
- <Dialog open={addAlunoDialogOpen} onOpenChange={setAddAlunoDialogOpen}>
1107
- <DialogContent className=" max-w-3xl">
2737
+ <CreateLmsStudentPersonSheet
2738
+ open={createStudentDialogOpen}
2739
+ onOpenChange={setCreateStudentDialogOpen}
2740
+ classGroupId={id}
2741
+ onEnrolled={handleStudentEnrolled}
2742
+ title={t('dialogs.createStudent.title')}
2743
+ description={t('dialogs.createStudent.description')}
2744
+ errorMessage={t('toasts.error')}
2745
+ alreadyEnrolledMessage="Esta pessoa ja esta matriculada na turma."
2746
+ />
2747
+
2748
+ <Dialog
2749
+ open={studentProfileDialogOpen}
2750
+ onOpenChange={setStudentProfileDialogOpen}
2751
+ >
2752
+ <DialogContent className="max-w-lg">
1108
2753
  <DialogHeader>
1109
- <DialogTitle>{t('dialogs.addStudents.title')}</DialogTitle>
2754
+ <DialogTitle>{t('dialogs.studentProfile.title')}</DialogTitle>
1110
2755
  <DialogDescription>
1111
- {t('dialogs.addStudents.description')}
2756
+ {t('dialogs.studentProfile.description')}
1112
2757
  </DialogDescription>
1113
2758
  </DialogHeader>
1114
- <div className="space-y-2 max-h-64 overflow-y-auto py-2">
1115
- {ALUNOS_DISPONIVEIS.map((aluno) => (
1116
- <div
1117
- key={aluno.id}
1118
- className="flex items-center gap-3 rounded-lg border p-3 hover:bg-muted/50 cursor-pointer"
1119
- onClick={() =>
1120
- setAlunosToAdd((prev) =>
1121
- prev.includes(aluno.id)
1122
- ? prev.filter((x) => x !== aluno.id)
1123
- : [...prev, aluno.id]
1124
- )
1125
- }
1126
- >
1127
- <Checkbox checked={alunosToAdd.includes(aluno.id)} />
1128
- <Avatar className="size-9">
1129
- <AvatarFallback className="text-xs">
1130
- {aluno.nome
1131
- .split(' ')
1132
- .map((n) => n[0])
1133
- .join('')
1134
- .slice(0, 2)}
1135
- </AvatarFallback>
1136
- </Avatar>
1137
- <div className="flex-1 min-w-0">
1138
- <p className="text-sm font-medium truncate">{aluno.nome}</p>
1139
- <p className="text-xs text-muted-foreground truncate">
1140
- {aluno.email}
2759
+
2760
+ {loadingStudentProfile ? (
2761
+ <div className="flex items-center justify-center py-8">
2762
+ <Loader2 className="size-5 animate-spin" />
2763
+ </div>
2764
+ ) : selectedStudentProfile ? (
2765
+ <div className="space-y-3">
2766
+ <div className="rounded-lg border p-3">
2767
+ <p className="text-xs text-muted-foreground">
2768
+ {t('dialogs.studentProfile.fields.name')}
2769
+ </p>
2770
+ <p className="font-medium">{selectedStudentProfile.nome}</p>
2771
+ </div>
2772
+ <div className="rounded-lg border p-3">
2773
+ <p className="text-xs text-muted-foreground">
2774
+ {t('dialogs.studentProfile.fields.email')}
2775
+ </p>
2776
+ <p className="font-medium">
2777
+ {selectedStudentProfile.email || '—'}
2778
+ </p>
2779
+ </div>
2780
+ <div className="rounded-lg border p-3">
2781
+ <p className="text-xs text-muted-foreground">
2782
+ {t('dialogs.studentProfile.fields.phone')}
2783
+ </p>
2784
+ <p className="font-medium">
2785
+ {selectedStudentProfile.telefone || '—'}
2786
+ </p>
2787
+ </div>
2788
+ <div className="grid grid-cols-2 gap-3">
2789
+ <div className="rounded-lg border p-3">
2790
+ <p className="text-xs text-muted-foreground">
2791
+ {t('dialogs.studentProfile.fields.progress')}
2792
+ </p>
2793
+ <p className="font-medium">
2794
+ {selectedStudentProfile.progresso}%
2795
+ </p>
2796
+ </div>
2797
+ <div className="rounded-lg border p-3">
2798
+ <p className="text-xs text-muted-foreground">
2799
+ {t('dialogs.studentProfile.fields.enrolledAt')}
2800
+ </p>
2801
+ <p className="font-medium">
2802
+ {selectedStudentProfile.matriculadoEm
2803
+ ? format(
2804
+ parseSessionDate(
2805
+ selectedStudentProfile.matriculadoEm
2806
+ ),
2807
+ 'dd/MM/yyyy'
2808
+ )
2809
+ : '—'}
1141
2810
  </p>
1142
2811
  </div>
1143
2812
  </div>
1144
- ))}
1145
- </div>
2813
+ </div>
2814
+ ) : null}
2815
+
1146
2816
  <DialogFooter>
1147
2817
  <Button
1148
2818
  variant="outline"
1149
- onClick={() => {
1150
- setAddAlunoDialogOpen(false);
1151
- setAlunosToAdd([]);
1152
- }}
2819
+ onClick={() => setStudentProfileDialogOpen(false)}
1153
2820
  >
1154
2821
  {t('common.cancel')}
1155
2822
  </Button>
1156
- <Button
1157
- onClick={handleAddAlunos}
1158
- disabled={alunosToAdd.length === 0}
1159
- >
1160
- {t('dialogs.addStudents.confirm')}{' '}
1161
- {alunosToAdd.length > 0 && `(${alunosToAdd.length})`}
2823
+ <Button onClick={openEditStudentSheet} className="gap-2">
2824
+ <Pencil className="size-4" />
2825
+ {t('common.edit')}
1162
2826
  </Button>
1163
2827
  </DialogFooter>
1164
2828
  </DialogContent>
1165
2829
  </Dialog>
1166
2830
 
2831
+ <Sheet open={editStudentSheetOpen} onOpenChange={setEditStudentSheetOpen}>
2832
+ <SheetContent
2833
+ side="right"
2834
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
2835
+ >
2836
+ <SheetHeader>
2837
+ <SheetTitle>{t('dialogs.editStudent.title')}</SheetTitle>
2838
+ <SheetDescription>
2839
+ {t('dialogs.editStudent.description')}
2840
+ </SheetDescription>
2841
+ </SheetHeader>
2842
+
2843
+ <Form {...editStudentForm}>
2844
+ <form
2845
+ onSubmit={handleUpdateStudentProfile}
2846
+ className="mt-6 space-y-3 px-4"
2847
+ >
2848
+ <FormField
2849
+ control={editStudentForm.control}
2850
+ name="name"
2851
+ render={({ field }) => (
2852
+ <FormItem>
2853
+ <FormLabel>
2854
+ {t('dialogs.editStudent.fields.name')}
2855
+ </FormLabel>
2856
+ <FormControl>
2857
+ <Input
2858
+ {...field}
2859
+ placeholder={t(
2860
+ 'dialogs.editStudent.fields.namePlaceholder'
2861
+ )}
2862
+ />
2863
+ </FormControl>
2864
+ <FormMessage />
2865
+ </FormItem>
2866
+ )}
2867
+ />
2868
+ <FormField
2869
+ control={editStudentForm.control}
2870
+ name="email"
2871
+ render={({ field }) => (
2872
+ <FormItem>
2873
+ <FormLabel>
2874
+ {t('dialogs.editStudent.fields.email')}
2875
+ </FormLabel>
2876
+ <FormControl>
2877
+ <Input
2878
+ type="email"
2879
+ {...field}
2880
+ placeholder={t(
2881
+ 'dialogs.editStudent.fields.emailPlaceholder'
2882
+ )}
2883
+ />
2884
+ </FormControl>
2885
+ <FormMessage />
2886
+ </FormItem>
2887
+ )}
2888
+ />
2889
+ <FormField
2890
+ control={editStudentForm.control}
2891
+ name="phone"
2892
+ render={({ field }) => (
2893
+ <FormItem>
2894
+ <FormLabel>
2895
+ {t('dialogs.editStudent.fields.phone')}
2896
+ </FormLabel>
2897
+ <FormControl>
2898
+ <Input
2899
+ {...field}
2900
+ placeholder={t(
2901
+ 'dialogs.editStudent.fields.phonePlaceholder'
2902
+ )}
2903
+ />
2904
+ </FormControl>
2905
+ <FormMessage />
2906
+ </FormItem>
2907
+ )}
2908
+ />
2909
+
2910
+ <SheetFooter className="mt-6 px-0">
2911
+ <Button
2912
+ type="button"
2913
+ variant="outline"
2914
+ onClick={() => {
2915
+ setEditStudentSheetOpen(false);
2916
+ setStudentProfileDialogOpen(true);
2917
+ }}
2918
+ >
2919
+ {t('common.cancel')}
2920
+ </Button>
2921
+ <Button
2922
+ type="submit"
2923
+ disabled={savingStudentProfile}
2924
+ className="gap-2"
2925
+ >
2926
+ {savingStudentProfile && (
2927
+ <Loader2 className="size-4 animate-spin" />
2928
+ )}
2929
+ {t('common.save')}
2930
+ </Button>
2931
+ </SheetFooter>
2932
+ </form>
2933
+ </Form>
2934
+ </SheetContent>
2935
+ </Sheet>
2936
+
1167
2937
  {/* ── Dialog Remover Aluno ─────────────────────────────────────────────── */}
1168
2938
  <Dialog
1169
2939
  open={removeAlunoDialogOpen}
@@ -1191,7 +2961,13 @@ export default function TurmaDetalhePage() {
1191
2961
  >
1192
2962
  {t('common.cancel')}
1193
2963
  </Button>
1194
- <Button variant="destructive" onClick={handleRemoveAluno}>
2964
+ <Button
2965
+ variant="destructive"
2966
+ onClick={handleRemoveAluno}
2967
+ disabled={removingStudent}
2968
+ className="gap-2"
2969
+ >
2970
+ {removingStudent && <Loader2 className="size-4 animate-spin" />}
1195
2971
  {t('common.remove')}
1196
2972
  </Button>
1197
2973
  </DialogFooter>
@@ -1200,7 +2976,10 @@ export default function TurmaDetalhePage() {
1200
2976
 
1201
2977
  {/* ── Sheet Aula ───────────────────────────────────────────────────────── */}
1202
2978
  <Sheet open={aulaSheetOpen} onOpenChange={setAulaSheetOpen}>
1203
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
2979
+ <SheetContent
2980
+ side="right"
2981
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
2982
+ >
1204
2983
  <SheetHeader>
1205
2984
  <SheetTitle>
1206
2985
  {editingAula
@@ -1213,196 +2992,554 @@ export default function TurmaDetalhePage() {
1213
2992
  : t('sheet.lessonForm.descriptionCreate')}
1214
2993
  </SheetDescription>
1215
2994
  </SheetHeader>
1216
- <form onSubmit={handleSaveAula} className="mt-6 space-y-5 px-4">
1217
- <Field>
1218
- <FieldLabel>{t('sheet.lessonForm.fields.title')}</FieldLabel>
1219
- <Input
1220
- {...aulaForm.register('titulo')}
1221
- placeholder={t('sheet.lessonForm.fields.titlePlaceholder')}
1222
- />
1223
- {aulaForm.formState.errors.titulo && (
1224
- <FieldError>
1225
- {aulaForm.formState.errors.titulo.message}
1226
- </FieldError>
1227
- )}
1228
- </Field>
1229
-
1230
- <div className="grid grid-cols-2 gap-4">
1231
- <Field>
1232
- <FieldLabel>{t('sheet.lessonForm.fields.date')}</FieldLabel>
1233
- <Input type="date" {...aulaForm.register('data')} />
1234
- {aulaForm.formState.errors.data && (
1235
- <FieldError>
1236
- {aulaForm.formState.errors.data.message}
1237
- </FieldError>
1238
- )}
1239
- </Field>
1240
- <Field>
1241
- <FieldLabel>{t('sheet.lessonForm.fields.type')}</FieldLabel>
1242
- <Controller
1243
- name="tipo"
1244
- control={aulaForm.control}
1245
- render={({ field }) => (
1246
- <Select value={field.value} onValueChange={field.onChange}>
1247
- <SelectTrigger>
1248
- <SelectValue
1249
- placeholder={t('sheet.lessonForm.fields.select')}
1250
- />
1251
- </SelectTrigger>
1252
- <SelectContent>
1253
- <SelectItem value="online">
1254
- {tClasses('type.online')}
1255
- </SelectItem>
1256
- <SelectItem value="presencial">
1257
- {tClasses('type.presencial')}
1258
- </SelectItem>
1259
- </SelectContent>
1260
- </Select>
2995
+ <Tabs
2996
+ value={aulaSheetTab}
2997
+ onValueChange={(value) =>
2998
+ setAulaSheetTab(value as 'aulas' | 'chamada')
2999
+ }
3000
+ className="mt-6 px-4"
3001
+ >
3002
+ <TabsList className="grid w-full grid-cols-2">
3003
+ <TabsTrigger value="aulas">{t('sheet.tabs.lessons')}</TabsTrigger>
3004
+ <TabsTrigger value="chamada">
3005
+ {t('sheet.tabs.attendance')}
3006
+ </TabsTrigger>
3007
+ </TabsList>
3008
+
3009
+ <TabsContent value="aulas" className="mt-4">
3010
+ <form onSubmit={handleSaveAula} className="space-y-5">
3011
+ <Field>
3012
+ <FieldLabel>{t('sheet.lessonForm.fields.title')}</FieldLabel>
3013
+ <Input
3014
+ {...aulaForm.register('titulo')}
3015
+ placeholder={t('sheet.lessonForm.fields.titlePlaceholder')}
3016
+ />
3017
+ {aulaForm.formState.errors.titulo && (
3018
+ <FieldError>
3019
+ {aulaForm.formState.errors.titulo.message}
3020
+ </FieldError>
1261
3021
  )}
1262
- />
1263
- </Field>
1264
- </div>
3022
+ </Field>
1265
3023
 
1266
- <div className="grid grid-cols-2 gap-4">
1267
- <Field>
1268
- <FieldLabel>
1269
- {t('sheet.lessonForm.fields.startTime')}
1270
- </FieldLabel>
1271
- <Input type="time" {...aulaForm.register('horaInicio')} />
1272
- </Field>
1273
- <Field>
1274
- <FieldLabel>{t('sheet.lessonForm.fields.endTime')}</FieldLabel>
1275
- <Input type="time" {...aulaForm.register('horaFim')} />
1276
- </Field>
1277
- </div>
3024
+ <div className="grid grid-cols-2 gap-4">
3025
+ <Field>
3026
+ <FieldLabel>{t('sheet.lessonForm.fields.date')}</FieldLabel>
3027
+ <Input type="date" {...aulaForm.register('data')} />
3028
+ {aulaForm.formState.errors.data && (
3029
+ <FieldError>
3030
+ {aulaForm.formState.errors.data.message}
3031
+ </FieldError>
3032
+ )}
3033
+ </Field>
3034
+ <Field>
3035
+ <FieldLabel>{t('sheet.lessonForm.fields.type')}</FieldLabel>
3036
+ <Controller
3037
+ name="tipo"
3038
+ control={aulaForm.control}
3039
+ render={({ field }) => (
3040
+ <Select
3041
+ value={field.value}
3042
+ onValueChange={field.onChange}
3043
+ >
3044
+ <SelectTrigger>
3045
+ <SelectValue
3046
+ placeholder={t('sheet.lessonForm.fields.select')}
3047
+ />
3048
+ </SelectTrigger>
3049
+ <SelectContent>
3050
+ <SelectItem value="online">
3051
+ {tClasses('type.online')}
3052
+ </SelectItem>
3053
+ <SelectItem value="presencial">
3054
+ {tClasses('type.presencial')}
3055
+ </SelectItem>
3056
+ </SelectContent>
3057
+ </Select>
3058
+ )}
3059
+ />
3060
+ </Field>
3061
+ </div>
1278
3062
 
1279
- <Field>
1280
- <FieldLabel>{t('sheet.lessonForm.fields.location')}</FieldLabel>
1281
- <Input
1282
- {...aulaForm.register('local')}
1283
- placeholder={t('sheet.lessonForm.fields.locationPlaceholder')}
1284
- />
1285
- {aulaForm.formState.errors.local && (
1286
- <FieldError>
1287
- {aulaForm.formState.errors.local.message}
1288
- </FieldError>
1289
- )}
1290
- </Field>
1291
-
1292
- <SheetFooter className="mt-6 px-0">
1293
- <Button type="submit">
1294
- {editingAula
1295
- ? t('sheet.lessonForm.actions.save')
1296
- : t('sheet.lessonForm.actions.create')}
1297
- </Button>
1298
- </SheetFooter>
1299
- </form>
1300
- </SheetContent>
1301
- </Sheet>
3063
+ <div className="grid grid-cols-2 gap-4">
3064
+ <Field>
3065
+ <FieldLabel>
3066
+ {t('sheet.lessonForm.fields.startTime')}
3067
+ </FieldLabel>
3068
+ <Input type="time" {...aulaForm.register('horaInicio')} />
3069
+ </Field>
3070
+ <Field>
3071
+ <FieldLabel>
3072
+ {t('sheet.lessonForm.fields.endTime')}
3073
+ </FieldLabel>
3074
+ <Input type="time" {...aulaForm.register('horaFim')} />
3075
+ </Field>
3076
+ </div>
1302
3077
 
1303
- {/* ── Sheet Presenca ───────────────────────────────────────────────────── */}
1304
- <Sheet open={presencaSheetOpen} onOpenChange={setPresencaSheetOpen}>
1305
- <SheetContent className="w-full sm:max-w-xl overflow-y-auto">
1306
- <SheetHeader>
1307
- <SheetTitle>{t('sheet.attendance.title')}</SheetTitle>
1308
- <SheetDescription>
1309
- {selectedAulaForPresenca && (
1310
- <>
1311
- <strong>{selectedAulaForPresenca.titulo}</strong> -{' '}
1312
- {format(selectedAulaForPresenca.data, 'dd/MM/yyyy')}
1313
- </>
1314
- )}
1315
- </SheetDescription>
1316
- </SheetHeader>
3078
+ <Field>
3079
+ <FieldLabel>{t('sheet.lessonForm.fields.color')}</FieldLabel>
3080
+ <Controller
3081
+ name="cor"
3082
+ control={aulaForm.control}
3083
+ render={({ field }) => (
3084
+ <>
3085
+ <div className="flex flex-wrap gap-2">
3086
+ {SESSION_COLOR_PALETTE.map((colorOption) => {
3087
+ const selected = field.value === colorOption;
1317
3088
 
1318
- <div className="mt-6 space-y-2 px-4">
1319
- <div className="flex items-center justify-between text-sm text-muted-foreground mb-3">
1320
- <span>
1321
- {t('sheet.attendance.summary', {
1322
- present: presencaList.filter((p) => p.presente).length,
1323
- total: presencaList.length,
1324
- })}
1325
- </span>
1326
- <div className="flex gap-2">
1327
- <Button
1328
- variant="outline"
1329
- size="sm"
1330
- onClick={() =>
1331
- setPresencaList((prev) =>
1332
- prev.map((p) => ({ ...p, presente: true }))
1333
- )
1334
- }
1335
- >
1336
- {t('sheet.attendance.allPresent')}
1337
- </Button>
1338
- <Button
1339
- variant="outline"
1340
- size="sm"
1341
- onClick={() =>
1342
- setPresencaList((prev) =>
1343
- prev.map((p) => ({ ...p, presente: false }))
1344
- )
1345
- }
1346
- >
1347
- {t('sheet.attendance.allAbsent')}
1348
- </Button>
1349
- </div>
1350
- </div>
3089
+ return (
3090
+ <button
3091
+ key={colorOption}
3092
+ type="button"
3093
+ aria-label={colorOption}
3094
+ title={colorOption}
3095
+ className={`h-8 w-8 cursor-pointer rounded-full border-2 transition ${selected ? 'border-foreground scale-110' : 'border-transparent hover:scale-105'}`}
3096
+ style={{ backgroundColor: colorOption }}
3097
+ onClick={() => field.onChange(colorOption)}
3098
+ />
3099
+ );
3100
+ })}
3101
+ </div>
3102
+ <p className="text-xs text-muted-foreground">
3103
+ {t('sheet.lessonForm.fields.colorHint')}
3104
+ </p>
3105
+ </>
3106
+ )}
3107
+ />
3108
+ {aulaForm.formState.errors.cor && (
3109
+ <FieldError>
3110
+ {aulaForm.formState.errors.cor.message}
3111
+ </FieldError>
3112
+ )}
3113
+ </Field>
1351
3114
 
1352
- {alunos.map((aluno) => {
1353
- const presenca = presencaList.find((p) => p.alunoId === aluno.id);
1354
- if (!presenca) return null;
1355
- return (
1356
- <div
1357
- key={aluno.id}
1358
- className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${presenca.presente ? 'bg-emerald-50/50 border-emerald-200' : 'bg-red-50/50 border-red-200'}`}
1359
- >
1360
- <div className="flex items-center gap-3">
1361
- <Avatar className="size-9">
1362
- <AvatarFallback className="text-xs">
1363
- {aluno.nome
1364
- .split(' ')
1365
- .map((n) => n[0])
1366
- .join('')
1367
- .slice(0, 2)}
1368
- </AvatarFallback>
1369
- </Avatar>
1370
- <span className="font-medium">{aluno.nome}</span>
1371
- </div>
1372
- <div className="flex items-center gap-3">
1373
- <span
1374
- className={`text-sm font-medium ${presenca.presente ? 'text-emerald-600' : 'text-red-600'}`}
1375
- >
1376
- {presenca.presente
1377
- ? t('sheet.attendance.present')
1378
- : t('sheet.attendance.absent')}
1379
- </span>
1380
- <Switch
1381
- checked={presenca.presente}
1382
- onCheckedChange={() => togglePresenca(aluno.id)}
1383
- />
3115
+ {showRecurrenceFields ? (
3116
+ <div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
3117
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
3118
+ <Field>
3119
+ <FieldLabel>
3120
+ {t('sheet.lessonForm.recurrence.label')}
3121
+ </FieldLabel>
3122
+ <Controller
3123
+ name="recurrenceFrequency"
3124
+ control={aulaForm.control}
3125
+ render={({ field }) => (
3126
+ <Select
3127
+ value={field.value}
3128
+ onValueChange={(value) =>
3129
+ field.onChange(
3130
+ value as SessionRecurrenceFrequency
3131
+ )
3132
+ }
3133
+ >
3134
+ <SelectTrigger>
3135
+ <SelectValue />
3136
+ </SelectTrigger>
3137
+ <SelectContent>
3138
+ {!editingAula?.isRecurring ? (
3139
+ <SelectItem value="none">
3140
+ {t(
3141
+ 'sheet.lessonForm.recurrence.options.none'
3142
+ )}
3143
+ </SelectItem>
3144
+ ) : null}
3145
+ <SelectItem value="daily">
3146
+ {t(
3147
+ 'sheet.lessonForm.recurrence.options.daily'
3148
+ )}
3149
+ </SelectItem>
3150
+ <SelectItem value="weekly">
3151
+ {t(
3152
+ 'sheet.lessonForm.recurrence.options.weekly'
3153
+ )}
3154
+ </SelectItem>
3155
+ <SelectItem value="monthly">
3156
+ {t(
3157
+ 'sheet.lessonForm.recurrence.options.monthly'
3158
+ )}
3159
+ </SelectItem>
3160
+ <SelectItem value="yearly">
3161
+ {t(
3162
+ 'sheet.lessonForm.recurrence.options.yearly'
3163
+ )}
3164
+ </SelectItem>
3165
+ </SelectContent>
3166
+ </Select>
3167
+ )}
3168
+ />
3169
+ </Field>
3170
+
3171
+ {recurrenceFrequency !== 'none' ? (
3172
+ <Field>
3173
+ <FieldLabel>
3174
+ {t('sheet.lessonForm.recurrence.until')}
3175
+ </FieldLabel>
3176
+ <Input
3177
+ type="date"
3178
+ {...aulaForm.register('recurrenceUntil')}
3179
+ />
3180
+ {aulaForm.formState.errors.recurrenceUntil && (
3181
+ <FieldError>
3182
+ {
3183
+ aulaForm.formState.errors.recurrenceUntil
3184
+ .message
3185
+ }
3186
+ </FieldError>
3187
+ )}
3188
+ </Field>
3189
+ ) : null}
3190
+ </div>
3191
+
3192
+ {recurrenceFrequency === 'weekly' ? (
3193
+ <Field>
3194
+ <FieldLabel>
3195
+ {t('sheet.lessonForm.recurrence.daysLabel')}
3196
+ </FieldLabel>
3197
+ <div className="grid grid-cols-4 gap-2 sm:grid-cols-7">
3198
+ {RECURRENCE_DAY_LABELS.map((day) => {
3199
+ const selected = recurrenceDaysOfWeek.includes(
3200
+ day.value
3201
+ );
3202
+
3203
+ return (
3204
+ <Button
3205
+ key={day.value}
3206
+ type="button"
3207
+ variant={selected ? 'default' : 'outline'}
3208
+ size="sm"
3209
+ className="h-9"
3210
+ onClick={() => {
3211
+ const nextValue = selected
3212
+ ? recurrenceDaysOfWeek.filter(
3213
+ (value) => value !== day.value
3214
+ )
3215
+ : [...recurrenceDaysOfWeek, day.value];
3216
+
3217
+ aulaForm.setValue(
3218
+ 'recurrenceDaysOfWeek',
3219
+ nextValue,
3220
+ {
3221
+ shouldDirty: true,
3222
+ shouldTouch: true,
3223
+ shouldValidate: true,
3224
+ }
3225
+ );
3226
+ }}
3227
+ >
3228
+ {t(day.labelKey)}
3229
+ </Button>
3230
+ );
3231
+ })}
3232
+ </div>
3233
+ <p className="text-xs text-muted-foreground">
3234
+ {t('sheet.lessonForm.recurrence.weeklyHint')}
3235
+ </p>
3236
+ </Field>
3237
+ ) : null}
3238
+
3239
+ {editingAula?.isRecurring ? (
3240
+ <Field>
3241
+ <FieldLabel>
3242
+ {t('sheet.lessonForm.recurrence.applyScope')}
3243
+ </FieldLabel>
3244
+ <Controller
3245
+ name="applyScope"
3246
+ control={aulaForm.control}
3247
+ render={({ field }) => (
3248
+ <Select
3249
+ value={field.value}
3250
+ onValueChange={field.onChange}
3251
+ >
3252
+ <SelectTrigger>
3253
+ <SelectValue />
3254
+ </SelectTrigger>
3255
+ <SelectContent>
3256
+ <SelectItem value="single">
3257
+ {t(
3258
+ 'sheet.lessonForm.recurrence.applyScopeSingle'
3259
+ )}
3260
+ </SelectItem>
3261
+ <SelectItem value="series">
3262
+ {t(
3263
+ 'sheet.lessonForm.recurrence.applyScopeSeries'
3264
+ )}
3265
+ </SelectItem>
3266
+ </SelectContent>
3267
+ </Select>
3268
+ )}
3269
+ />
3270
+ <p className="text-xs text-muted-foreground">
3271
+ {t('sheet.lessonForm.recurrence.applyScopeHint')}
3272
+ </p>
3273
+ </Field>
3274
+ ) : null}
1384
3275
  </div>
3276
+ ) : null}
3277
+
3278
+ <Field>
3279
+ <FieldLabel>
3280
+ {t('sheet.lessonForm.fields.instructor')}
3281
+ </FieldLabel>
3282
+ <Controller
3283
+ name="instrutorId"
3284
+ control={aulaForm.control}
3285
+ render={({ field }) => (
3286
+ <div className="flex items-end gap-2">
3287
+ <div className="flex-1">
3288
+ <Popover
3289
+ open={instructorOpen}
3290
+ onOpenChange={setInstructorOpen}
3291
+ >
3292
+ <PopoverTrigger asChild>
3293
+ <Button
3294
+ type="button"
3295
+ variant="outline"
3296
+ role="combobox"
3297
+ className="w-full justify-between"
3298
+ >
3299
+ <span className="truncate text-left">
3300
+ {instructorOptions.find(
3301
+ (instructor) =>
3302
+ String(instructor.id) === field.value
3303
+ )?.name ||
3304
+ editingAula?.instructorName ||
3305
+ t(
3306
+ 'sheet.lessonForm.fields.instructorPlaceholder'
3307
+ )}
3308
+ </span>
3309
+ {loadingInstructors ? (
3310
+ <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-60" />
3311
+ ) : (
3312
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
3313
+ )}
3314
+ </Button>
3315
+ </PopoverTrigger>
3316
+ <PopoverContent className="p-0" align="start">
3317
+ <Command shouldFilter={false}>
3318
+ <CommandInput
3319
+ placeholder={t(
3320
+ 'sheet.lessonForm.fields.instructorPlaceholder'
3321
+ )}
3322
+ value={instructorSearch}
3323
+ onValueChange={setInstructorSearch}
3324
+ />
3325
+ <CommandList>
3326
+ <CommandEmpty>
3327
+ <div className="flex flex-col items-center gap-3 px-2 py-4">
3328
+ <p className="text-sm text-muted-foreground">
3329
+ {t(
3330
+ 'sheet.lessonForm.fields.instructorNotFound'
3331
+ )}
3332
+ </p>
3333
+ <Button
3334
+ type="button"
3335
+ variant="outline"
3336
+ size="sm"
3337
+ className="w-full"
3338
+ onClick={() => {
3339
+ setInstructorOpen(false);
3340
+ setCreateInstructorDialogOpen(true);
3341
+ }}
3342
+ >
3343
+ <Plus className="mr-2 h-4 w-4" />
3344
+ {t(
3345
+ 'sheet.lessonForm.fields.createInstructor'
3346
+ )}
3347
+ </Button>
3348
+ </div>
3349
+ </CommandEmpty>
3350
+ <CommandGroup>
3351
+ {instructorOptions.map((instructor) => (
3352
+ <CommandItem
3353
+ key={instructor.id}
3354
+ value={`${instructor.name}-${instructor.id}`}
3355
+ onSelect={() => {
3356
+ field.onChange(String(instructor.id));
3357
+ setInstructorOpen(false);
3358
+ setInstructorSearch('');
3359
+ }}
3360
+ >
3361
+ {instructor.name}
3362
+ </CommandItem>
3363
+ ))}
3364
+ </CommandGroup>
3365
+ </CommandList>
3366
+ </Command>
3367
+ </PopoverContent>
3368
+ </Popover>
3369
+ </div>
3370
+
3371
+ <Button
3372
+ type="button"
3373
+ variant="outline"
3374
+ size="icon"
3375
+ className="shrink-0"
3376
+ onClick={() => setCreateInstructorDialogOpen(true)}
3377
+ aria-label={t(
3378
+ 'sheet.lessonForm.fields.createInstructor'
3379
+ )}
3380
+ >
3381
+ <Plus className="h-4 w-4" />
3382
+ </Button>
3383
+ </div>
3384
+ )}
3385
+ />
3386
+ </Field>
3387
+
3388
+ <Field>
3389
+ <FieldLabel>
3390
+ {t('sheet.lessonForm.fields.location')}
3391
+ </FieldLabel>
3392
+ <Input
3393
+ {...aulaForm.register('local')}
3394
+ placeholder={t(
3395
+ 'sheet.lessonForm.fields.locationPlaceholder'
3396
+ )}
3397
+ />
3398
+ {aulaForm.formState.errors.local && (
3399
+ <FieldError>
3400
+ {aulaForm.formState.errors.local.message}
3401
+ </FieldError>
3402
+ )}
3403
+ </Field>
3404
+
3405
+ <SheetFooter className="mt-6 px-0">
3406
+ <Button type="submit" disabled={savingAula} className="gap-2">
3407
+ {savingAula && <Loader2 className="size-4 animate-spin" />}
3408
+ {editingAula
3409
+ ? t('sheet.lessonForm.actions.save')
3410
+ : t('sheet.lessonForm.actions.create')}
3411
+ </Button>
3412
+ </SheetFooter>
3413
+ </form>
3414
+ </TabsContent>
3415
+
3416
+ <TabsContent value="chamada" className="mt-4 space-y-4">
3417
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
3418
+ <span>
3419
+ {t('sheet.attendance.summary', {
3420
+ present: presencaList.filter(
3421
+ (p) => p.selecionado && p.presente
3422
+ ).length,
3423
+ total: presencaList.filter((p) => p.selecionado).length,
3424
+ })}
3425
+ </span>
3426
+ <div className="flex gap-2">
3427
+ <Button
3428
+ variant="outline"
3429
+ size="sm"
3430
+ onClick={() =>
3431
+ setPresencaList((prev) =>
3432
+ prev.map((p) =>
3433
+ p.selecionado ? { ...p, presente: true } : p
3434
+ )
3435
+ )
3436
+ }
3437
+ >
3438
+ {t('sheet.attendance.allPresent')}
3439
+ </Button>
3440
+ <Button
3441
+ variant="outline"
3442
+ size="sm"
3443
+ onClick={() =>
3444
+ setPresencaList((prev) =>
3445
+ prev.map((p) =>
3446
+ p.selecionado ? { ...p, presente: false } : p
3447
+ )
3448
+ )
3449
+ }
3450
+ >
3451
+ {t('sheet.attendance.allAbsent')}
3452
+ </Button>
1385
3453
  </div>
1386
- );
1387
- })}
1388
- </div>
3454
+ </div>
1389
3455
 
1390
- <SheetFooter className="mt-6">
1391
- <Button
1392
- onClick={handleSavePresenca}
1393
- disabled={savingPresenca}
1394
- className="gap-2"
1395
- >
1396
- {savingPresenca ? (
1397
- <Loader2 className="size-4 animate-spin" />
1398
- ) : (
1399
- <Save className="size-4" />
1400
- )}
1401
- {t('sheet.attendance.save')}
1402
- </Button>
1403
- </SheetFooter>
3456
+ <div className="space-y-2">
3457
+ {alunos.map((aluno) => {
3458
+ const presenca = presencaList.find(
3459
+ (p) => p.alunoId === aluno.id
3460
+ );
3461
+ if (!presenca) return null;
3462
+ return (
3463
+ <div
3464
+ key={aluno.id}
3465
+ className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${presenca.selecionado ? (presenca.presente ? 'bg-emerald-50/50 border-emerald-200' : 'bg-red-50/50 border-red-200') : 'bg-muted/30 border-border/60'}`}
3466
+ >
3467
+ <div className="flex items-center gap-3">
3468
+ <Checkbox
3469
+ checked={presenca.selecionado}
3470
+ onCheckedChange={() => toggleParticipante(aluno.id)}
3471
+ aria-label={t('sheet.attendance.participantLabel')}
3472
+ />
3473
+ <Avatar className="size-9">
3474
+ <AvatarFallback className="text-xs">
3475
+ {aluno.nome
3476
+ .split(' ')
3477
+ .map((n) => n[0])
3478
+ .join('')
3479
+ .slice(0, 2)}
3480
+ </AvatarFallback>
3481
+ </Avatar>
3482
+ <span className="font-medium">{aluno.nome}</span>
3483
+ </div>
3484
+ <div className="flex items-center gap-3">
3485
+ <span
3486
+ className={`text-sm font-medium ${presenca.selecionado ? (presenca.presente ? 'text-emerald-600' : 'text-red-600') : 'text-muted-foreground'}`}
3487
+ >
3488
+ {presenca.selecionado
3489
+ ? presenca.presente
3490
+ ? t('sheet.attendance.present')
3491
+ : t('sheet.attendance.absent')
3492
+ : t('sheet.attendance.notParticipant')}
3493
+ </span>
3494
+ <Switch
3495
+ checked={presenca.presente}
3496
+ disabled={!presenca.selecionado}
3497
+ onCheckedChange={() => togglePresenca(aluno.id)}
3498
+ />
3499
+ </div>
3500
+ </div>
3501
+ );
3502
+ })}
3503
+ </div>
3504
+
3505
+ <SheetFooter className="mt-6 px-0">
3506
+ <Button
3507
+ onClick={handleSaveAttendanceOnly}
3508
+ disabled={savingAula || savingPresenca}
3509
+ className="gap-2"
3510
+ >
3511
+ {savingAula || savingPresenca ? (
3512
+ <Loader2 className="size-4 animate-spin" />
3513
+ ) : (
3514
+ <Save className="size-4" />
3515
+ )}
3516
+ {t('sheet.attendance.save')}
3517
+ </Button>
3518
+ </SheetFooter>
3519
+ </TabsContent>
3520
+ </Tabs>
1404
3521
  </SheetContent>
1405
3522
  </Sheet>
3523
+
3524
+ <CreateLmsPersonSheet
3525
+ open={createInstructorDialogOpen}
3526
+ onOpenChange={setCreateInstructorDialogOpen}
3527
+ onCreated={handleInstructorCreated}
3528
+ title={t('sheet.lessonForm.createInstructorTitle')}
3529
+ description={t('sheet.lessonForm.createInstructorDescription')}
3530
+ errorMessage={t('sheet.lessonForm.createInstructorError')}
3531
+ defaultQualificationSlugs={['class-sessions']}
3532
+ />
3533
+
3534
+ <ClassFormSheet
3535
+ open={editSheetOpen}
3536
+ onOpenChange={setEditSheetOpen}
3537
+ classId={id}
3538
+ onSaved={async () => {
3539
+ await Promise.all([refetchTurma(), refetchAulas(), refetchAlunos()]);
3540
+ notifyLmsDataUpdated();
3541
+ }}
3542
+ />
1406
3543
  </Page>
1407
3544
  );
1408
3545
  }