@hed-hog/lms 0.0.304 → 0.0.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. package/README.md +413 -401
  2. package/dist/certificate/certificate.controller.d.ts +90 -0
  3. package/dist/certificate/certificate.controller.d.ts.map +1 -0
  4. package/dist/certificate/certificate.controller.js +121 -0
  5. package/dist/certificate/certificate.controller.js.map +1 -0
  6. package/dist/certificate/certificate.module.d.ts +3 -0
  7. package/dist/certificate/certificate.module.d.ts.map +1 -0
  8. package/dist/certificate/certificate.module.js +26 -0
  9. package/dist/certificate/certificate.module.js.map +1 -0
  10. package/dist/certificate/certificate.service.d.ts +115 -0
  11. package/dist/certificate/certificate.service.d.ts.map +1 -0
  12. package/dist/certificate/certificate.service.js +343 -0
  13. package/dist/certificate/certificate.service.js.map +1 -0
  14. package/dist/certificate/dto/create-certificate-template.dto.d.ts +8 -0
  15. package/dist/certificate/dto/create-certificate-template.dto.d.ts.map +1 -0
  16. package/dist/certificate/dto/create-certificate-template.dto.js +44 -0
  17. package/dist/certificate/dto/create-certificate-template.dto.js.map +1 -0
  18. package/dist/certificate/dto/update-certificate-template.dto.d.ts +6 -0
  19. package/dist/certificate/dto/update-certificate-template.dto.d.ts.map +1 -0
  20. package/dist/certificate/dto/update-certificate-template.dto.js +9 -0
  21. package/dist/certificate/dto/update-certificate-template.dto.js.map +1 -0
  22. package/dist/class-group/class-group.controller.d.ts +305 -0
  23. package/dist/class-group/class-group.controller.d.ts.map +1 -0
  24. package/dist/class-group/class-group.controller.js +257 -0
  25. package/dist/class-group/class-group.controller.js.map +1 -0
  26. package/dist/class-group/class-group.module.d.ts +3 -0
  27. package/dist/class-group/class-group.module.d.ts.map +1 -0
  28. package/dist/class-group/class-group.module.js +25 -0
  29. package/dist/class-group/class-group.module.js.map +1 -0
  30. package/dist/class-group/class-group.service.d.ts +354 -0
  31. package/dist/class-group/class-group.service.d.ts.map +1 -0
  32. package/dist/class-group/class-group.service.js +1356 -0
  33. package/dist/class-group/class-group.service.js.map +1 -0
  34. package/dist/class-group/dto/create-class-group.dto.d.ts +33 -0
  35. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -0
  36. package/dist/class-group/dto/create-class-group.dto.js +165 -0
  37. package/dist/class-group/dto/create-class-group.dto.js.map +1 -0
  38. package/dist/class-group/dto/create-session.dto.d.ts +22 -0
  39. package/dist/class-group/dto/create-session.dto.d.ts.map +1 -0
  40. package/dist/class-group/dto/create-session.dto.js +117 -0
  41. package/dist/class-group/dto/create-session.dto.js.map +1 -0
  42. package/dist/class-group/dto/enrollment.dto.d.ts +22 -0
  43. package/dist/class-group/dto/enrollment.dto.d.ts.map +1 -0
  44. package/dist/class-group/dto/enrollment.dto.js +89 -0
  45. package/dist/class-group/dto/enrollment.dto.js.map +1 -0
  46. package/dist/class-group/dto/update-class-group.dto.d.ts +6 -0
  47. package/dist/class-group/dto/update-class-group.dto.d.ts.map +1 -0
  48. package/dist/class-group/dto/update-class-group.dto.js +9 -0
  49. package/dist/class-group/dto/update-class-group.dto.js.map +1 -0
  50. package/dist/class-group/dto/update-session.dto.d.ts +7 -0
  51. package/dist/class-group/dto/update-session.dto.d.ts.map +1 -0
  52. package/dist/class-group/dto/update-session.dto.js +24 -0
  53. package/dist/class-group/dto/update-session.dto.js.map +1 -0
  54. package/dist/course/course-structure.controller.d.ts +127 -0
  55. package/dist/course/course-structure.controller.d.ts.map +1 -0
  56. package/dist/course/course-structure.controller.js +115 -0
  57. package/dist/course/course-structure.controller.js.map +1 -0
  58. package/dist/course/course-structure.service.d.ts +142 -0
  59. package/dist/course/course-structure.service.d.ts.map +1 -0
  60. package/dist/course/course-structure.service.js +445 -0
  61. package/dist/course/course-structure.service.js.map +1 -0
  62. package/dist/course/course.controller.d.ts +195 -0
  63. package/dist/course/course.controller.d.ts.map +1 -0
  64. package/dist/course/course.controller.js +104 -0
  65. package/dist/course/course.controller.js.map +1 -0
  66. package/dist/course/course.module.d.ts +3 -0
  67. package/dist/course/course.module.d.ts.map +1 -0
  68. package/dist/course/course.module.js +28 -0
  69. package/dist/course/course.module.js.map +1 -0
  70. package/dist/course/course.service.d.ts +215 -0
  71. package/dist/course/course.service.d.ts.map +1 -0
  72. package/dist/course/course.service.js +743 -0
  73. package/dist/course/course.service.js.map +1 -0
  74. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +24 -0
  75. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -0
  76. package/dist/course/dto/create-course-structure-lesson.dto.js +118 -0
  77. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -0
  78. package/dist/course/dto/create-course-structure-session.dto.d.ts +7 -0
  79. package/dist/course/dto/create-course-structure-session.dto.d.ts.map +1 -0
  80. package/dist/course/dto/create-course-structure-session.dto.js +40 -0
  81. package/dist/course/dto/create-course-structure-session.dto.js.map +1 -0
  82. package/dist/course/dto/create-course.dto.d.ts +26 -0
  83. package/dist/course/dto/create-course.dto.d.ts.map +1 -0
  84. package/dist/course/dto/create-course.dto.js +138 -0
  85. package/dist/course/dto/create-course.dto.js.map +1 -0
  86. package/dist/course/dto/update-course-structure-lesson.dto.d.ts +6 -0
  87. package/dist/course/dto/update-course-structure-lesson.dto.d.ts.map +1 -0
  88. package/dist/course/dto/update-course-structure-lesson.dto.js +9 -0
  89. package/dist/course/dto/update-course-structure-lesson.dto.js.map +1 -0
  90. package/dist/course/dto/update-course-structure-session.dto.d.ts +6 -0
  91. package/dist/course/dto/update-course-structure-session.dto.d.ts.map +1 -0
  92. package/dist/course/dto/update-course-structure-session.dto.js +9 -0
  93. package/dist/course/dto/update-course-structure-session.dto.js.map +1 -0
  94. package/dist/course/dto/update-course.dto.d.ts +6 -0
  95. package/dist/course/dto/update-course.dto.d.ts.map +1 -0
  96. package/dist/course/dto/update-course.dto.js +9 -0
  97. package/dist/course/dto/update-course.dto.js.map +1 -0
  98. package/dist/dashboard/dashboard.controller.d.ts +101 -0
  99. package/dist/dashboard/dashboard.controller.d.ts.map +1 -0
  100. package/dist/dashboard/dashboard.controller.js +40 -0
  101. package/dist/dashboard/dashboard.controller.js.map +1 -0
  102. package/dist/dashboard/dashboard.module.d.ts +3 -0
  103. package/dist/dashboard/dashboard.module.d.ts.map +1 -0
  104. package/dist/dashboard/dashboard.module.js +25 -0
  105. package/dist/dashboard/dashboard.module.js.map +1 -0
  106. package/dist/dashboard/dashboard.service.d.ts +130 -0
  107. package/dist/dashboard/dashboard.service.d.ts.map +1 -0
  108. package/dist/dashboard/dashboard.service.js +626 -0
  109. package/dist/dashboard/dashboard.service.js.map +1 -0
  110. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts +4 -0
  111. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts.map +1 -0
  112. package/dist/enterprise/dto/add-enterprise-class-group.dto.js +22 -0
  113. package/dist/enterprise/dto/add-enterprise-class-group.dto.js.map +1 -0
  114. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts +5 -0
  115. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts.map +1 -0
  116. package/dist/enterprise/dto/add-enterprise-course.dto.js +27 -0
  117. package/dist/enterprise/dto/add-enterprise-course.dto.js.map +1 -0
  118. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts +5 -0
  119. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  121. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  123. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  125. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  126. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  127. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  129. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  130. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  131. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  133. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  135. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  137. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  139. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  141. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  142. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  143. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  144. package/dist/enterprise/enterprise.controller.js +311 -0
  145. package/dist/enterprise/enterprise.controller.js.map +1 -0
  146. package/dist/enterprise/enterprise.module.d.ts +3 -0
  147. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.module.js +25 -0
  149. package/dist/enterprise/enterprise.module.js.map +1 -0
  150. package/dist/enterprise/enterprise.service.d.ts +282 -0
  151. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.service.js +627 -0
  153. package/dist/enterprise/enterprise.service.js.map +1 -0
  154. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  155. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  156. package/dist/evaluation/evaluation.controller.js +76 -0
  157. package/dist/evaluation/evaluation.controller.js.map +1 -0
  158. package/dist/evaluation/evaluation.module.d.ts +3 -0
  159. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.module.js +25 -0
  161. package/dist/evaluation/evaluation.module.js.map +1 -0
  162. package/dist/evaluation/evaluation.service.d.ts +67 -0
  163. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.service.js +378 -0
  165. package/dist/evaluation/evaluation.service.js.map +1 -0
  166. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  167. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  168. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  169. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  170. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  171. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam.dto.js +63 -0
  173. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  174. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  175. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  177. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  178. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  179. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  181. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  182. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  183. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  185. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  186. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  187. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  191. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  193. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam.dto.js +9 -0
  197. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  198. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  199. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  200. package/dist/exam/exam-attempt.controller.js +84 -0
  201. package/dist/exam/exam-attempt.controller.js.map +1 -0
  202. package/dist/exam/exam-attempt.service.d.ts +302 -0
  203. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.service.js +776 -0
  205. package/dist/exam/exam-attempt.service.js.map +1 -0
  206. package/dist/exam/exam.controller.d.ts +162 -0
  207. package/dist/exam/exam.controller.d.ts.map +1 -0
  208. package/dist/exam/exam.controller.js +158 -0
  209. package/dist/exam/exam.controller.js.map +1 -0
  210. package/dist/exam/exam.module.d.ts +3 -0
  211. package/dist/exam/exam.module.d.ts.map +1 -0
  212. package/dist/exam/exam.module.js +27 -0
  213. package/dist/exam/exam.module.js.map +1 -0
  214. package/dist/exam/exam.service.d.ts +179 -0
  215. package/dist/exam/exam.service.d.ts.map +1 -0
  216. package/dist/exam/exam.service.js +597 -0
  217. package/dist/exam/exam.service.js.map +1 -0
  218. package/dist/index.d.ts +28 -0
  219. package/dist/index.d.ts.map +1 -1
  220. package/dist/index.js +28 -0
  221. package/dist/index.js.map +1 -1
  222. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  223. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  224. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  225. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  226. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  227. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  229. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  230. package/dist/instructor/instructor.controller.d.ts +52 -0
  231. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  232. package/dist/instructor/instructor.controller.js +98 -0
  233. package/dist/instructor/instructor.controller.js.map +1 -0
  234. package/dist/instructor/instructor.module.d.ts +3 -0
  235. package/dist/instructor/instructor.module.d.ts.map +1 -0
  236. package/dist/instructor/instructor.module.js +25 -0
  237. package/dist/instructor/instructor.module.js.map +1 -0
  238. package/dist/instructor/instructor.service.d.ts +79 -0
  239. package/dist/instructor/instructor.service.d.ts.map +1 -0
  240. package/dist/instructor/instructor.service.js +528 -0
  241. package/dist/instructor/instructor.service.js.map +1 -0
  242. package/dist/lms.module.d.ts.map +1 -1
  243. package/dist/lms.module.js +36 -4
  244. package/dist/lms.module.js.map +1 -1
  245. package/dist/reports/reports.controller.d.ts +69 -0
  246. package/dist/reports/reports.controller.d.ts.map +1 -0
  247. package/dist/reports/reports.controller.js +40 -0
  248. package/dist/reports/reports.controller.js.map +1 -0
  249. package/dist/reports/reports.module.d.ts +3 -0
  250. package/dist/reports/reports.module.d.ts.map +1 -0
  251. package/dist/reports/reports.module.js +25 -0
  252. package/dist/reports/reports.module.js.map +1 -0
  253. package/dist/reports/reports.service.d.ts +80 -0
  254. package/dist/reports/reports.service.d.ts.map +1 -0
  255. package/dist/reports/reports.service.js +366 -0
  256. package/dist/reports/reports.service.js.map +1 -0
  257. package/dist/training/dto/create-training.dto.d.ts +19 -0
  258. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  259. package/dist/training/dto/create-training.dto.js +98 -0
  260. package/dist/training/dto/create-training.dto.js.map +1 -0
  261. package/dist/training/dto/update-training.dto.d.ts +6 -0
  262. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/update-training.dto.js +9 -0
  264. package/dist/training/dto/update-training.dto.js.map +1 -0
  265. package/dist/training/training.controller.d.ts +195 -0
  266. package/dist/training/training.controller.d.ts.map +1 -0
  267. package/dist/training/training.controller.js +104 -0
  268. package/dist/training/training.controller.js.map +1 -0
  269. package/dist/training/training.module.d.ts +3 -0
  270. package/dist/training/training.module.d.ts.map +1 -0
  271. package/dist/training/training.module.js +25 -0
  272. package/dist/training/training.module.js.map +1 -0
  273. package/dist/training/training.service.d.ts +213 -0
  274. package/dist/training/training.service.d.ts.map +1 -0
  275. package/dist/training/training.service.js +497 -0
  276. package/dist/training/training.service.js.map +1 -0
  277. package/hedhog/data/dashboard.yaml +6 -0
  278. package/hedhog/data/dashboard_component.yaml +153 -0
  279. package/hedhog/data/dashboard_component_role.yaml +97 -0
  280. package/hedhog/data/dashboard_item.yaml +167 -0
  281. package/hedhog/data/dashboard_role.yaml +6 -0
  282. package/hedhog/data/instructor_qualification.yaml +16 -0
  283. package/hedhog/data/menu.yaml +129 -19
  284. package/hedhog/data/role.yaml +25 -1
  285. package/hedhog/data/route.yaml +867 -0
  286. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  287. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  288. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  289. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  290. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  291. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  292. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  293. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  294. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  295. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  296. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  297. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  298. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  299. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  300. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  301. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  302. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  303. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  304. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  305. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  306. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  307. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  308. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  309. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  310. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  311. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  312. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  313. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  322. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  323. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  324. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  325. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  326. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  327. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  328. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  329. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +58 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +224 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  348. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  349. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  350. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  351. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  352. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  353. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  354. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  355. package/hedhog/frontend/app/page.tsx.ejs +3 -1220
  356. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  357. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  358. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  359. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  360. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  361. package/hedhog/frontend/messages/en.json +1606 -111
  362. package/hedhog/frontend/messages/pt.json +1636 -134
  363. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  364. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  365. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  366. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  367. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  368. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  369. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  370. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  371. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  372. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  373. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  374. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  375. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  376. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  377. package/hedhog/table/course.yaml +19 -1
  378. package/hedhog/table/course_class_group.yaml +8 -0
  379. package/hedhog/table/course_class_session.yaml +33 -0
  380. package/hedhog/table/course_instructor.yaml +27 -0
  381. package/hedhog/table/enterprise.yaml +29 -0
  382. package/hedhog/table/enterprise_class_group.yaml +20 -0
  383. package/hedhog/table/enterprise_course.yaml +23 -0
  384. package/hedhog/table/enterprise_student.yaml +24 -0
  385. package/hedhog/table/enterprise_user.yaml +35 -0
  386. package/hedhog/table/instructor_qualification.yaml +26 -0
  387. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  388. package/hedhog/table/question.yaml +6 -0
  389. package/package.json +6 -6
  390. package/src/certificate/certificate.controller.ts +83 -0
  391. package/src/certificate/certificate.module.ts +13 -0
  392. package/src/certificate/certificate.service.ts +413 -0
  393. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  394. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  395. package/src/class-group/class-group.controller.ts +189 -0
  396. package/src/class-group/class-group.module.ts +12 -0
  397. package/src/class-group/class-group.service.ts +1802 -0
  398. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  399. package/src/class-group/dto/create-session.dto.ts +102 -0
  400. package/src/class-group/dto/enrollment.dto.ts +70 -0
  401. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  402. package/src/class-group/dto/update-session.dto.ts +9 -0
  403. package/src/course/course-structure.controller.ts +85 -0
  404. package/src/course/course-structure.service.ts +525 -0
  405. package/src/course/course.controller.ts +69 -0
  406. package/src/course/course.module.ts +15 -0
  407. package/src/course/course.service.ts +920 -0
  408. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  409. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  410. package/src/course/dto/create-course.dto.ts +111 -0
  411. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  412. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  413. package/src/course/dto/update-course.dto.ts +4 -0
  414. package/src/dashboard/dashboard.controller.ts +14 -0
  415. package/src/dashboard/dashboard.module.ts +12 -0
  416. package/src/dashboard/dashboard.service.ts +726 -0
  417. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  418. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  419. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  420. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  421. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  422. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  423. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  424. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  425. package/src/enterprise/enterprise.controller.ts +233 -0
  426. package/src/enterprise/enterprise.module.ts +12 -0
  427. package/src/enterprise/enterprise.service.ts +712 -0
  428. package/src/evaluation/evaluation.controller.ts +44 -0
  429. package/src/evaluation/evaluation.module.ts +12 -0
  430. package/src/evaluation/evaluation.service.ts +394 -0
  431. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  432. package/src/exam/dto/create-exam.dto.ts +41 -0
  433. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  434. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  435. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  436. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  437. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  438. package/src/exam/dto/update-exam.dto.ts +4 -0
  439. package/src/exam/exam-attempt.controller.ts +65 -0
  440. package/src/exam/exam-attempt.service.ts +1008 -0
  441. package/src/exam/exam.controller.ts +102 -0
  442. package/src/exam/exam.module.ts +14 -0
  443. package/src/exam/exam.service.ts +784 -0
  444. package/src/index.ts +29 -0
  445. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  446. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  447. package/src/instructor/instructor.controller.ts +73 -0
  448. package/src/instructor/instructor.module.ts +12 -0
  449. package/src/instructor/instructor.service.ts +646 -0
  450. package/src/lms.module.ts +36 -4
  451. package/src/reports/reports.controller.ts +14 -0
  452. package/src/reports/reports.module.ts +12 -0
  453. package/src/reports/reports.service.ts +485 -0
  454. package/src/training/dto/create-training.dto.ts +81 -0
  455. package/src/training/dto/update-training.dto.ts +4 -0
  456. package/src/training/training.controller.ts +68 -0
  457. package/src/training/training.module.ts +12 -0
  458. package/src/training/training.service.ts +574 -0
@@ -5,6 +5,8 @@ import {
5
5
  Page,
6
6
  PageHeader,
7
7
  PaginationFooter,
8
+ SearchBar,
9
+ ViewModeToggle,
8
10
  } from '@/components/entity-list';
9
11
  import { Badge } from '@/components/ui/badge';
10
12
  import { Button } from '@/components/ui/button';
@@ -25,72 +27,50 @@ import {
25
27
  DropdownMenuSeparator,
26
28
  DropdownMenuTrigger,
27
29
  } from '@/components/ui/dropdown-menu';
28
- import {
29
- Field,
30
- FieldDescription,
31
- FieldError,
32
- FieldLabel,
33
- } from '@/components/ui/field';
34
- import { Input } from '@/components/ui/input';
35
- import {
36
- Select,
37
- SelectContent,
38
- SelectItem,
39
- SelectTrigger,
40
- SelectValue,
41
- } from '@/components/ui/select';
30
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
42
31
  import { Separator } from '@/components/ui/separator';
43
- import {
44
- Sheet,
45
- SheetContent,
46
- SheetDescription,
47
- SheetFooter,
48
- SheetHeader,
49
- SheetTitle,
50
- } from '@/components/ui/sheet';
51
32
  import { Skeleton } from '@/components/ui/skeleton';
52
- import { Switch } from '@/components/ui/switch';
53
- import { Textarea } from '@/components/ui/textarea';
33
+ import {
34
+ Table,
35
+ TableBody,
36
+ TableCell,
37
+ TableHead,
38
+ TableHeader,
39
+ TableRow,
40
+ } from '@/components/ui/table';
41
+ import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
42
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
54
43
  import { zodResolver } from '@hookform/resolvers/zod';
55
44
  import { AnimatePresence, motion } from 'framer-motion';
56
45
  import {
57
46
  AlertTriangle,
58
47
  Archive,
59
48
  Award,
60
- BarChart3,
61
49
  BookOpen,
62
50
  Eye,
63
- FileCheck,
64
- GraduationCap,
65
- LayoutDashboard,
66
51
  Loader2,
67
52
  MoreHorizontal,
68
53
  Pencil,
69
54
  Plus,
70
- Search,
71
55
  Star,
72
56
  Trash2,
73
57
  TrendingUp,
74
58
  Users,
75
- X,
76
59
  } from 'lucide-react';
77
60
  import { useTranslations } from 'next-intl';
78
- import { usePathname, useRouter } from 'next/navigation';
61
+ import { useRouter } from 'next/navigation';
79
62
  import { useEffect, useMemo, useRef, useState } from 'react';
80
- import { Controller, useForm } from 'react-hook-form';
63
+ import { useForm, useWatch } from 'react-hook-form';
81
64
  import { toast } from 'sonner';
82
- import { z } from 'zod';
83
-
84
- // ── Navigation ────────────────────────────────────────────────────────────────
65
+ import {
66
+ CourseCategoryOption,
67
+ CourseFormSheet,
68
+ CourseSheetFormValues,
69
+ DEFAULT_COURSE_FORM_VALUES,
70
+ getCourseSheetSchema,
71
+ } from '../_components/course-form-sheet';
85
72
 
86
- const NAV_ITEMS = [
87
- { label: 'Dashboard', href: '/', icon: LayoutDashboard },
88
- { label: 'Cursos', href: '/cursos', icon: BookOpen },
89
- { label: 'Turmas', href: '/turmas', icon: Users },
90
- { label: 'Exames', href: '/exames', icon: FileCheck },
91
- { label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
92
- { label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
93
- ];
73
+ const API_COURSES_CACHE_KEY = 'lms:courses:api-cache';
94
74
 
95
75
  // ── Types ─────────────────────────────────────────────────────────────────────
96
76
 
@@ -100,6 +80,8 @@ interface Curso {
100
80
  nomeInterno: string;
101
81
  tituloComercial: string;
102
82
  descricao: string;
83
+ primaryColor?: string | null;
84
+ secondaryColor?: string | null;
103
85
  nivel: 'iniciante' | 'intermediario' | 'avancado';
104
86
  status: 'ativo' | 'rascunho' | 'arquivado';
105
87
  categorias: string[];
@@ -110,60 +92,155 @@ interface Curso {
110
92
  criadoEm: string;
111
93
  }
112
94
 
113
- // ── Zod Schema ────────────────────────────────────────────────────────────────
114
-
115
- function getCursoSchema(t: (key: string) => string) {
116
- return z.object({
117
- codigo: z
118
- .string()
119
- .min(2, t('form.validation.codeMinLength'))
120
- .max(16, t('form.validation.codeMaxLength'))
121
- .regex(/^[A-Z0-9-]+$/i, t('form.validation.codePattern')),
122
- nomeInterno: z.string().min(3, t('form.validation.internalNameMinLength')),
123
- tituloComercial: z
124
- .string()
125
- .min(3, t('form.validation.commercialTitleMinLength')),
126
- descricao: z.string().min(10, t('form.validation.descriptionMinLength')),
127
- nivel: z.enum(['iniciante', 'intermediario', 'avancado'], {
128
- errorMap: () => ({ message: t('form.validation.levelRequired') }),
129
- }),
130
- status: z.enum(['ativo', 'rascunho', 'arquivado'], {
131
- errorMap: () => ({ message: t('form.validation.statusRequired') }),
132
- }),
133
- categorias: z
134
- .array(z.string())
135
- .min(1, t('form.validation.categoriesRequired')),
136
- destaque: z.boolean(),
137
- certificado: z.boolean(),
138
- listado: z.boolean(),
139
- });
140
- }
95
+ type ApiCourse = {
96
+ id: number;
97
+ code: string;
98
+ slug: string;
99
+ title: string;
100
+ description: string;
101
+ primaryColor?: string | null;
102
+ primaryContrastColor?: string | null;
103
+ secondaryColor?: string | null;
104
+ secondaryContrastColor?: string | null;
105
+ level: 'beginner' | 'intermediate' | 'advanced';
106
+ status: 'draft' | 'published' | 'archived';
107
+ categories: string[];
108
+ isFeatured: boolean;
109
+ hasCertificate: boolean;
110
+ isListed: boolean;
111
+ enrollmentCount: number;
112
+ createdAt: string;
113
+ };
141
114
 
142
- type CursoForm = {
143
- codigo: string;
144
- nomeInterno: string;
145
- tituloComercial: string;
146
- descricao: string;
147
- nivel: 'iniciante' | 'intermediario' | 'avancado';
148
- status: 'ativo' | 'rascunho' | 'arquivado';
149
- categorias: string[];
150
- destaque: boolean;
151
- certificado: boolean;
152
- listado: boolean;
115
+ type ApiCourseList = {
116
+ data: ApiCourse[];
117
+ total: number;
118
+ page: number;
119
+ pageSize: number;
120
+ lastPage: number;
153
121
  };
154
122
 
155
- // ── Constants ─────────────────────────────────────────────────────────────────
123
+ type ApiCourseStats = {
124
+ totalCourses: number;
125
+ publishedCourses: number;
126
+ featuredCourses: number;
127
+ totalStudents: number;
128
+ };
156
129
 
157
- const CATEGORIAS = [
158
- 'Tecnologia',
159
- 'Design',
160
- 'Gestao',
161
- 'Marketing',
162
- 'Financas',
163
- 'Saude',
164
- 'Idiomas',
165
- 'Direito',
166
- ];
130
+ type ApiCategory = {
131
+ id: number;
132
+ slug: string;
133
+ name: string;
134
+ status?: 'active' | 'inactive';
135
+ };
136
+
137
+ type ApiCategoryList = {
138
+ data: ApiCategory[];
139
+ total: number;
140
+ page: number;
141
+ pageSize: number;
142
+ };
143
+
144
+ type Locale = {
145
+ id?: number;
146
+ code: string;
147
+ name: string;
148
+ };
149
+
150
+ type ViewMode = 'cards' | 'list';
151
+
152
+ function normalizeEnumValue(value?: string | null) {
153
+ return String(value ?? '')
154
+ .trim()
155
+ .normalize('NFD')
156
+ .replace(/[\u0300-\u036f]/g, '')
157
+ .toLowerCase();
158
+ }
159
+
160
+ function toPtLevel(level?: string | null): CourseSheetFormValues['nivel'] {
161
+ const normalizedLevel = normalizeEnumValue(level);
162
+
163
+ if (normalizedLevel === 'iniciante' || normalizedLevel === 'beginner') {
164
+ return 'iniciante';
165
+ }
166
+ if (
167
+ normalizedLevel === 'intermediario' ||
168
+ normalizedLevel === 'intermediate'
169
+ ) {
170
+ return 'intermediario';
171
+ }
172
+ if (normalizedLevel === 'avancado' || normalizedLevel === 'advanced') {
173
+ return 'avancado';
174
+ }
175
+
176
+ return 'iniciante';
177
+ }
178
+
179
+ function toPtStatus(status?: string | null): CourseSheetFormValues['status'] {
180
+ const normalizedStatus = normalizeEnumValue(status);
181
+
182
+ if (
183
+ normalizedStatus === 'ativo' ||
184
+ normalizedStatus === 'active' ||
185
+ normalizedStatus === 'published'
186
+ ) {
187
+ return 'ativo';
188
+ }
189
+ if (normalizedStatus === 'rascunho' || normalizedStatus === 'draft') {
190
+ return 'rascunho';
191
+ }
192
+ if (normalizedStatus === 'arquivado' || normalizedStatus === 'archived') {
193
+ return 'arquivado';
194
+ }
195
+
196
+ return 'rascunho';
197
+ }
198
+
199
+ function mapApiCourse(course: ApiCourse): Curso {
200
+ return {
201
+ id: course.id,
202
+ codigo: course.code,
203
+ nomeInterno: course.slug,
204
+ tituloComercial: course.title,
205
+ descricao: course.description ?? '',
206
+ primaryColor: course.primaryColor ?? null,
207
+ secondaryColor: course.secondaryColor ?? null,
208
+ nivel: toPtLevel(course.level),
209
+ status: toPtStatus(course.status),
210
+ categorias: course.categories ?? [],
211
+ destaque: course.isFeatured,
212
+ certificado: course.hasCertificate,
213
+ listado: course.isListed,
214
+ alunosInscritos: course.enrollmentCount ?? 0,
215
+ criadoEm: course.createdAt ?? '',
216
+ };
217
+ }
218
+
219
+ function toApiLevel(level: CourseSheetFormValues['nivel']) {
220
+ if (level === 'iniciante') return 'beginner';
221
+ if (level === 'intermediario') return 'intermediate';
222
+ return 'advanced';
223
+ }
224
+
225
+ function toApiStatus(status: CourseSheetFormValues['status']) {
226
+ if (status === 'ativo') return 'published';
227
+ if (status === 'rascunho') return 'draft';
228
+ return 'archived';
229
+ }
230
+
231
+ function getContrastColor(hex: string) {
232
+ const cleaned = hex.replace('#', '');
233
+ if (cleaned.length !== 6) return '#FFFFFF';
234
+
235
+ const r = parseInt(cleaned.slice(0, 2), 16);
236
+ const g = parseInt(cleaned.slice(2, 4), 16);
237
+ const b = parseInt(cleaned.slice(4, 6), 16);
238
+
239
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
240
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
241
+ }
242
+
243
+ // ── Constants ─────────────────────────────────────────────────────────────────
167
244
 
168
245
  const NIVEL_COLOR: Record<string, string> = {
169
246
  iniciante: 'bg-emerald-50 text-emerald-700 border-emerald-200',
@@ -179,251 +256,6 @@ const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
179
256
 
180
257
  // ── Seed Data ─────────────────────────────────────────────────────────────────
181
258
 
182
- const initialCursos: Curso[] = [
183
- {
184
- id: 1,
185
- codigo: 'REACT-ADV',
186
- nomeInterno: 'react-avancado',
187
- tituloComercial: 'React Avancado',
188
- descricao:
189
- 'Curso completo de React com hooks, context e patterns avancados para aplicacoes modernas.',
190
- nivel: 'avancado',
191
- status: 'ativo',
192
- categorias: ['Tecnologia'],
193
- destaque: true,
194
- certificado: true,
195
- listado: true,
196
- alunosInscritos: 245,
197
- criadoEm: '2025-01-15',
198
- },
199
- {
200
- id: 2,
201
- codigo: 'UX-FUND',
202
- nomeInterno: 'ux-fundamentals',
203
- tituloComercial: 'UX Design Fundamentals',
204
- descricao:
205
- 'Fundamentos de design de experiencia do usuario com ferramentas modernas e pesquisa.',
206
- nivel: 'iniciante',
207
- status: 'ativo',
208
- categorias: ['Design'],
209
- destaque: false,
210
- certificado: true,
211
- listado: true,
212
- alunosInscritos: 189,
213
- criadoEm: '2025-02-10',
214
- },
215
- {
216
- id: 3,
217
- codigo: 'GEST-AGIL',
218
- nomeInterno: 'gestao-agil',
219
- tituloComercial: 'Gestao de Projetos Ageis',
220
- descricao:
221
- 'Metodologias ageis para gestao de projetos incluindo Scrum, Kanban e SAFe.',
222
- nivel: 'intermediario',
223
- status: 'ativo',
224
- categorias: ['Gestao'],
225
- destaque: true,
226
- certificado: true,
227
- listado: true,
228
- alunosInscritos: 312,
229
- criadoEm: '2025-01-20',
230
- },
231
- {
232
- id: 4,
233
- codigo: 'MKT-DIG',
234
- nomeInterno: 'marketing-digital',
235
- tituloComercial: 'Marketing Digital Completo',
236
- descricao:
237
- 'Estrategias de marketing digital para negocios modernos incluindo SEO, SEM e redes sociais.',
238
- nivel: 'intermediario',
239
- status: 'rascunho',
240
- categorias: ['Marketing'],
241
- destaque: false,
242
- certificado: false,
243
- listado: false,
244
- alunosInscritos: 0,
245
- criadoEm: '2025-03-05',
246
- },
247
- {
248
- id: 5,
249
- codigo: 'PY-DS',
250
- nomeInterno: 'python-data-science',
251
- tituloComercial: 'Python para Data Science',
252
- descricao:
253
- 'Introducao a Python focado em ciencia de dados, analise estatistica e machine learning.',
254
- nivel: 'intermediario',
255
- status: 'ativo',
256
- categorias: ['Tecnologia'],
257
- destaque: false,
258
- certificado: true,
259
- listado: true,
260
- alunosInscritos: 178,
261
- criadoEm: '2025-02-28',
262
- },
263
- {
264
- id: 6,
265
- codigo: 'NODE-API',
266
- nomeInterno: 'node-completo',
267
- tituloComercial: 'Node.js Completo',
268
- descricao:
269
- 'Backend com Node.js, Express e bancos de dados relacionais e NoSQL para APIs robustas.',
270
- nivel: 'avancado',
271
- status: 'ativo',
272
- categorias: ['Tecnologia'],
273
- destaque: true,
274
- certificado: true,
275
- listado: true,
276
- alunosInscritos: 156,
277
- criadoEm: '2025-01-10',
278
- },
279
- {
280
- id: 7,
281
- codigo: 'FIGMA-INI',
282
- nomeInterno: 'figma-iniciantes',
283
- tituloComercial: 'Figma para Iniciantes',
284
- descricao:
285
- 'Aprenda a usar o Figma do zero para criar interfaces profissionais e prototipos interativos.',
286
- nivel: 'iniciante',
287
- status: 'arquivado',
288
- categorias: ['Design'],
289
- destaque: false,
290
- certificado: true,
291
- listado: false,
292
- alunosInscritos: 420,
293
- criadoEm: '2024-11-05',
294
- },
295
- {
296
- id: 8,
297
- codigo: 'LIDER-COM',
298
- nomeInterno: 'lideranca-comunicacao',
299
- tituloComercial: 'Lideranca e Comunicacao',
300
- descricao:
301
- 'Desenvolva habilidades de lideranca e comunicacao assertiva para ambientes corporativos.',
302
- nivel: 'iniciante',
303
- status: 'ativo',
304
- categorias: ['Gestao'],
305
- destaque: false,
306
- certificado: true,
307
- listado: true,
308
- alunosInscritos: 98,
309
- criadoEm: '2025-03-12',
310
- },
311
- {
312
- id: 9,
313
- codigo: 'SEO-ADV',
314
- nomeInterno: 'seo-avancado',
315
- tituloComercial: 'SEO Avancado',
316
- descricao:
317
- 'Tecnicas avancadas de otimizacao para motores de busca, conteudo web e performance.',
318
- nivel: 'avancado',
319
- status: 'rascunho',
320
- categorias: ['Marketing', 'Tecnologia'],
321
- destaque: false,
322
- certificado: false,
323
- listado: false,
324
- alunosInscritos: 0,
325
- criadoEm: '2025-04-01',
326
- },
327
- {
328
- id: 10,
329
- codigo: 'TS-PRAT',
330
- nomeInterno: 'typescript-pratica',
331
- tituloComercial: 'TypeScript na Pratica',
332
- descricao:
333
- 'TypeScript aplicado em projetos reais com boas praticas, design patterns e testes.',
334
- nivel: 'intermediario',
335
- status: 'ativo',
336
- categorias: ['Tecnologia'],
337
- destaque: true,
338
- certificado: true,
339
- listado: true,
340
- alunosInscritos: 201,
341
- criadoEm: '2025-02-15',
342
- },
343
- {
344
- id: 11,
345
- codigo: 'DS-SYS',
346
- nomeInterno: 'design-system',
347
- tituloComercial: 'Design System Completo',
348
- descricao:
349
- 'Como criar e manter um design system escalavel para grandes equipes de produto.',
350
- nivel: 'avancado',
351
- status: 'ativo',
352
- categorias: ['Design', 'Tecnologia'],
353
- destaque: false,
354
- certificado: true,
355
- listado: true,
356
- alunosInscritos: 87,
357
- criadoEm: '2025-03-20',
358
- },
359
- {
360
- id: 12,
361
- codigo: 'EXCEL-BIZ',
362
- nomeInterno: 'excel-negocios',
363
- tituloComercial: 'Excel para Negocios',
364
- descricao:
365
- 'Domine Excel com formulas avancadas, dashboards profissionais e analise de dados.',
366
- nivel: 'iniciante',
367
- status: 'ativo',
368
- categorias: ['Gestao', 'Financas'],
369
- destaque: false,
370
- certificado: true,
371
- listado: true,
372
- alunosInscritos: 534,
373
- criadoEm: '2024-10-15',
374
- },
375
- {
376
- id: 13,
377
- codigo: 'FIN-PESSOAL',
378
- nomeInterno: 'financas-pessoais',
379
- tituloComercial: 'Financas Pessoais',
380
- descricao:
381
- 'Aprenda a gerenciar suas financas, investir e planejar sua aposentadoria de forma inteligente.',
382
- nivel: 'iniciante',
383
- status: 'ativo',
384
- categorias: ['Financas'],
385
- destaque: false,
386
- certificado: true,
387
- listado: true,
388
- alunosInscritos: 342,
389
- criadoEm: '2025-01-08',
390
- },
391
- {
392
- id: 14,
393
- codigo: 'FLUTTER-MOB',
394
- nomeInterno: 'flutter-mobile',
395
- tituloComercial: 'Flutter para Mobile',
396
- descricao:
397
- 'Desenvolvimento de aplicativos moveis multiplataforma com Flutter e Dart do zero.',
398
- nivel: 'intermediario',
399
- status: 'ativo',
400
- categorias: ['Tecnologia'],
401
- destaque: true,
402
- certificado: true,
403
- listado: true,
404
- alunosInscritos: 167,
405
- criadoEm: '2025-04-02',
406
- },
407
- {
408
- id: 15,
409
- codigo: 'DIR-TRAB',
410
- nomeInterno: 'direito-trabalhista',
411
- tituloComercial: 'Direito Trabalhista Essencial',
412
- descricao:
413
- 'Conceitos essenciais de direito trabalhista para gestores de RH e empreendedores.',
414
- nivel: 'iniciante',
415
- status: 'rascunho',
416
- categorias: ['Direito', 'Gestao'],
417
- destaque: false,
418
- certificado: false,
419
- listado: false,
420
- alunosInscritos: 0,
421
- criadoEm: '2025-04-10',
422
- },
423
- ];
424
-
425
- const PAGE_SIZES = [6, 12, 24];
426
-
427
259
  // ── Animations ────────────────────────────────────────────────────────────────
428
260
 
429
261
  const fadeUp = {
@@ -440,35 +272,34 @@ const stagger = {
440
272
 
441
273
  export default function CursosPage() {
442
274
  const t = useTranslations('lms.CoursesPage');
443
- const pathname = usePathname();
444
275
  const router = useRouter();
276
+ const { request } = useApp();
445
277
 
446
278
  // UI
447
- const [loading, setLoading] = useState(true);
448
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
449
279
  const [sheetOpen, setSheetOpen] = useState(false);
450
280
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
451
281
  const [saving, setSaving] = useState(false);
452
282
 
453
283
  // Data
454
- const [cursos, setCursos] = useState<Curso[]>(initialCursos);
455
284
  const [editingCurso, setEditingCurso] = useState<Curso | null>(null);
456
285
  const [cursoToDelete, setCursoToDelete] = useState<Curso | null>(null);
286
+ const [cachedCourseList, setCachedCourseList] =
287
+ useState<ApiCourseList | null>(null);
457
288
 
458
289
  // Selection
459
290
  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
460
291
 
461
- // Search/filter state (controlled by form submit)
292
+ // Search/filter state (reactive)
462
293
  const [buscaInput, setBuscaInput] = useState('');
463
294
  const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
464
295
  const [filtroNivelInput, setFiltroNivelInput] = useState('todos');
465
296
  const [filtroCatInput, setFiltroCatInput] = useState('todos');
466
-
467
- // Applied filters (only updated on submit)
468
- const [buscaApplied, setBuscaApplied] = useState('');
469
- const [filtroStatusApplied, setFiltroStatusApplied] = useState('todos');
470
- const [filtroNivelApplied, setFiltroNivelApplied] = useState('todos');
471
- const [filtroCatApplied, setFiltroCatApplied] = useState('todos');
297
+ const [debouncedBuscaInput, setDebouncedBuscaInput] = useState('');
298
+ const [viewMode, setViewMode] = usePersistedViewMode<ViewMode>({
299
+ storageKey: 'lms:courses:view-mode',
300
+ defaultValue: 'cards',
301
+ allowedValues: ['cards', 'list'],
302
+ });
472
303
 
473
304
  // Pagination
474
305
  const [currentPage, setCurrentPage] = useState(1);
@@ -479,86 +310,243 @@ export default function CursosPage() {
479
310
  new Map()
480
311
  );
481
312
 
482
- const form = useForm<CursoForm>({
483
- resolver: zodResolver(getCursoSchema(t)),
484
- defaultValues: {
485
- codigo: '',
486
- nomeInterno: '',
487
- tituloComercial: '',
488
- descricao: '',
489
- nivel: 'iniciante',
490
- status: 'rascunho',
491
- categorias: [],
492
- destaque: false,
493
- certificado: true,
494
- listado: false,
313
+ const form = useForm<CourseSheetFormValues>({
314
+ resolver: zodResolver(getCourseSheetSchema(t)),
315
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
316
+ });
317
+ const watchedFormValues = useWatch({ control: form.control });
318
+
319
+ const {
320
+ data: courseList,
321
+ refetch: refetchCourses,
322
+ isFetching,
323
+ isLoading,
324
+ } = useQuery<ApiCourseList>({
325
+ queryKey: [
326
+ 'lms-courses',
327
+ currentPage,
328
+ pageSize,
329
+ debouncedBuscaInput,
330
+ filtroStatusInput,
331
+ filtroNivelInput,
332
+ filtroCatInput,
333
+ ],
334
+ queryFn: async () => {
335
+ const response = await request<ApiCourseList>({
336
+ url: '/lms/courses',
337
+ method: 'GET',
338
+ params: {
339
+ page: currentPage,
340
+ pageSize,
341
+ ...(debouncedBuscaInput ? { search: debouncedBuscaInput } : {}),
342
+ ...(filtroStatusInput !== 'todos'
343
+ ? {
344
+ status:
345
+ filtroStatusInput === 'ativo'
346
+ ? 'published'
347
+ : filtroStatusInput === 'rascunho'
348
+ ? 'draft'
349
+ : 'archived',
350
+ }
351
+ : {}),
352
+ ...(filtroNivelInput !== 'todos'
353
+ ? {
354
+ level:
355
+ filtroNivelInput === 'iniciante'
356
+ ? 'beginner'
357
+ : filtroNivelInput === 'intermediario'
358
+ ? 'intermediate'
359
+ : 'advanced',
360
+ }
361
+ : {}),
362
+ ...(filtroCatInput !== 'todos' ? { category: filtroCatInput } : {}),
363
+ },
364
+ });
365
+ return response.data;
495
366
  },
496
367
  });
497
368
 
498
369
  useEffect(() => {
499
- const t = setTimeout(() => setLoading(false), 800);
500
- return () => clearTimeout(t);
501
- }, []);
370
+ const timer = setTimeout(() => {
371
+ setDebouncedBuscaInput(buscaInput.trim());
372
+ }, 350);
373
+ return () => clearTimeout(timer);
374
+ }, [buscaInput]);
502
375
 
503
- // ── Filtering (applied on search submit) ─────────────────────────────────
504
-
505
- const filteredCursos = useMemo(() => {
506
- return cursos.filter((c) => {
507
- const q = buscaApplied.toLowerCase();
508
- const matchSearch =
509
- !q ||
510
- c.codigo.toLowerCase().includes(q) ||
511
- c.tituloComercial.toLowerCase().includes(q) ||
512
- c.nomeInterno.toLowerCase().includes(q);
513
- const matchStatus =
514
- filtroStatusApplied === 'todos' || c.status === filtroStatusApplied;
515
- const matchNivel =
516
- filtroNivelApplied === 'todos' || c.nivel === filtroNivelApplied;
517
- const matchCategoria =
518
- filtroCatApplied === 'todos' || c.categorias.includes(filtroCatApplied);
519
- return matchSearch && matchStatus && matchNivel && matchCategoria;
520
- });
376
+ useEffect(() => {
377
+ setCurrentPage(1);
521
378
  }, [
522
- cursos,
523
- buscaApplied,
524
- filtroStatusApplied,
525
- filtroNivelApplied,
526
- filtroCatApplied,
379
+ debouncedBuscaInput,
380
+ filtroStatusInput,
381
+ filtroNivelInput,
382
+ filtroCatInput,
527
383
  ]);
528
384
 
529
- const totalPages = Math.max(1, Math.ceil(filteredCursos.length / pageSize));
530
- const safePage = Math.min(currentPage, totalPages);
531
- const paginatedCursos = filteredCursos.slice(
532
- (safePage - 1) * pageSize,
533
- safePage * pageSize
385
+ const { data: statsData } = useQuery<ApiCourseStats>({
386
+ queryKey: ['lms-courses-stats'],
387
+ queryFn: async () => {
388
+ const response = await request<ApiCourseStats>({
389
+ url: '/lms/courses/stats',
390
+ method: 'GET',
391
+ });
392
+ return response.data;
393
+ },
394
+ });
395
+
396
+ const { data: categoryListData, refetch: refetchCategoryOptions } =
397
+ useQuery<ApiCategoryList>({
398
+ queryKey: ['category-options'],
399
+ queryFn: async () => {
400
+ const response = await request<ApiCategoryList>({
401
+ url: '/category',
402
+ method: 'GET',
403
+ params: {
404
+ page: 1,
405
+ pageSize: 500,
406
+ status: 'all',
407
+ },
408
+ });
409
+
410
+ const payload = response.data as ApiCategoryList | ApiCategory[];
411
+ if (Array.isArray(payload)) {
412
+ return {
413
+ data: payload,
414
+ total: payload.length,
415
+ page: 1,
416
+ pageSize: payload.length,
417
+ };
418
+ }
419
+
420
+ return payload;
421
+ },
422
+ initialData: {
423
+ data: [],
424
+ total: 0,
425
+ page: 1,
426
+ pageSize: 500,
427
+ },
428
+ });
429
+
430
+ useEffect(() => {
431
+ if (sheetOpen) {
432
+ refetchCategoryOptions();
433
+ }
434
+ }, [sheetOpen, refetchCategoryOptions]);
435
+
436
+ useEffect(() => {
437
+ if (typeof window === 'undefined') return;
438
+ try {
439
+ const raw = window.localStorage.getItem(API_COURSES_CACHE_KEY);
440
+ if (!raw) return;
441
+ const parsed = JSON.parse(raw) as ApiCourseList;
442
+ if (parsed && Array.isArray(parsed.data)) setCachedCourseList(parsed);
443
+ } catch {
444
+ setCachedCourseList(null);
445
+ }
446
+ }, []);
447
+
448
+ useEffect(() => {
449
+ if (typeof window === 'undefined') return;
450
+ if (!courseList) return;
451
+ window.localStorage.setItem(
452
+ API_COURSES_CACHE_KEY,
453
+ JSON.stringify(courseList)
454
+ );
455
+ setCachedCourseList(courseList);
456
+ }, [courseList]);
457
+
458
+ const effectiveCourseList = courseList ?? cachedCourseList;
459
+ const initialLoading = isLoading && !effectiveCourseList;
460
+ const cardsRefreshing = isFetching && !!effectiveCourseList;
461
+
462
+ const cursos = useMemo(
463
+ () => (effectiveCourseList?.data ?? []).map((item) => mapApiCourse(item)),
464
+ [effectiveCourseList]
465
+ );
466
+ const previewCurso = useMemo(() => {
467
+ if (!sheetOpen || !editingCurso) return null;
468
+
469
+ return {
470
+ ...editingCurso,
471
+ nomeInterno: watchedFormValues.nomeInterno ?? editingCurso.nomeInterno,
472
+ tituloComercial:
473
+ watchedFormValues.tituloComercial ?? editingCurso.tituloComercial,
474
+ descricao: watchedFormValues.descricao ?? editingCurso.descricao,
475
+ primaryColor: watchedFormValues.primaryColor ?? editingCurso.primaryColor,
476
+ secondaryColor:
477
+ watchedFormValues.secondaryColor ?? editingCurso.secondaryColor,
478
+ nivel:
479
+ (watchedFormValues.nivel as Curso['nivel'] | undefined) ??
480
+ editingCurso.nivel,
481
+ status:
482
+ (watchedFormValues.status as Curso['status'] | undefined) ??
483
+ editingCurso.status,
484
+ categorias: watchedFormValues.categorias ?? editingCurso.categorias,
485
+ } satisfies Curso;
486
+ }, [editingCurso, sheetOpen, watchedFormValues]);
487
+
488
+ const categoryOptions = useMemo<CourseCategoryOption[]>(
489
+ () =>
490
+ (categoryListData?.data ?? [])
491
+ .filter((category) => !!category.slug)
492
+ .map((category) => ({
493
+ value: category.slug,
494
+ label: category.name || category.slug,
495
+ }))
496
+ .sort((a, b) => a.label.localeCompare(b.label)),
497
+ [categoryListData]
534
498
  );
535
499
 
536
- function handleSearch(e: React.FormEvent) {
537
- e.preventDefault();
538
- setBuscaApplied(buscaInput);
539
- setFiltroStatusApplied(filtroStatusInput);
540
- setFiltroNivelApplied(filtroNivelInput);
541
- setFiltroCatApplied(filtroCatInput);
542
- setCurrentPage(1);
543
- }
500
+ const categoryLabelBySlug = useMemo(
501
+ () =>
502
+ categoryOptions.reduce<Record<string, string>>((acc, item) => {
503
+ acc[item.value] = item.label;
504
+ return acc;
505
+ }, {}),
506
+ [categoryOptions]
507
+ );
508
+
509
+ const paginatedCursos = useMemo(() => {
510
+ if (!previewCurso) return cursos;
544
511
 
512
+ return cursos.map((curso) =>
513
+ curso.id === previewCurso.id ? previewCurso : curso
514
+ );
515
+ }, [cursos, previewCurso]);
516
+ const totalPages = Math.max(1, effectiveCourseList?.lastPage ?? 1);
517
+ const safePage = Math.min(currentPage, totalPages);
545
518
  function clearFilters() {
546
519
  setBuscaInput('');
547
520
  setFiltroStatusInput('todos');
548
521
  setFiltroNivelInput('todos');
549
522
  setFiltroCatInput('todos');
550
- setBuscaApplied('');
551
- setFiltroStatusApplied('todos');
552
- setFiltroNivelApplied('todos');
553
- setFiltroCatApplied('todos');
523
+ setDebouncedBuscaInput('');
554
524
  setCurrentPage(1);
555
525
  }
556
526
 
557
527
  const hasActiveFilters =
558
- buscaApplied ||
559
- filtroStatusApplied !== 'todos' ||
560
- filtroNivelApplied !== 'todos' ||
561
- filtroCatApplied !== 'todos';
528
+ buscaInput ||
529
+ filtroStatusInput !== 'todos' ||
530
+ filtroNivelInput !== 'todos' ||
531
+ filtroCatInput !== 'todos';
532
+
533
+ function getLevelLabel(curso: Curso) {
534
+ return t(
535
+ `levels.${curso.nivel === 'iniciante' ? 'beginner' : curso.nivel === 'intermediario' ? 'intermediate' : 'advanced'}`
536
+ );
537
+ }
538
+
539
+ function getStatusLabel(curso: Curso) {
540
+ return t(
541
+ `status.${curso.status === 'ativo' ? 'active' : curso.status === 'rascunho' ? 'draft' : 'archived'}`
542
+ );
543
+ }
544
+
545
+ function openDeleteDialog(curso: Curso, e: React.MouseEvent) {
546
+ e.stopPropagation();
547
+ setCursoToDelete(curso);
548
+ setDeleteDialogOpen(true);
549
+ }
562
550
 
563
551
  // ── Double-click to navigate ──────────────────────────────────────────────
564
552
 
@@ -582,18 +570,11 @@ export default function CursosPage() {
582
570
  e.stopPropagation();
583
571
  setSelectedIds((prev) => {
584
572
  const next = new Set(prev);
585
- next.has(id) ? next.delete(id) : next.add(id);
586
- return next;
587
- });
588
- }
589
-
590
- function toggleSelectAll() {
591
- const allSelected = paginatedCursos.every((c) => selectedIds.has(c.id));
592
- setSelectedIds((prev) => {
593
- const next = new Set(prev);
594
- allSelected
595
- ? paginatedCursos.forEach((c) => next.delete(c.id))
596
- : paginatedCursos.forEach((c) => next.add(c.id));
573
+ if (next.has(id)) {
574
+ next.delete(id);
575
+ } else {
576
+ next.add(id);
577
+ }
597
578
  return next;
598
579
  });
599
580
  }
@@ -602,18 +583,7 @@ export default function CursosPage() {
602
583
 
603
584
  function openCreateSheet() {
604
585
  setEditingCurso(null);
605
- form.reset({
606
- codigo: '',
607
- nomeInterno: '',
608
- tituloComercial: '',
609
- descricao: '',
610
- nivel: 'iniciante',
611
- status: 'rascunho',
612
- categorias: [],
613
- destaque: false,
614
- certificado: true,
615
- listado: false,
616
- });
586
+ form.reset(DEFAULT_COURSE_FORM_VALUES);
617
587
  setSheetOpen(true);
618
588
  }
619
589
 
@@ -621,51 +591,70 @@ export default function CursosPage() {
621
591
  e?.stopPropagation();
622
592
  setEditingCurso(curso);
623
593
  form.reset({
624
- codigo: curso.codigo,
625
594
  nomeInterno: curso.nomeInterno,
626
595
  tituloComercial: curso.tituloComercial,
627
596
  descricao: curso.descricao,
597
+ primaryColor: curso.primaryColor || '#1D4ED8',
598
+ secondaryColor: curso.secondaryColor || '#111827',
628
599
  nivel: curso.nivel,
629
600
  status: curso.status,
630
601
  categorias: curso.categorias,
631
- destaque: curso.destaque,
632
- certificado: curso.certificado,
633
- listado: curso.listado,
634
602
  });
635
603
  setSheetOpen(true);
636
604
  }
637
605
 
638
- async function onSubmit(data: CursoForm) {
606
+ async function onSubmit(data: CourseSheetFormValues) {
639
607
  setSaving(true);
640
- await new Promise((r) => setTimeout(r, 500));
641
- if (editingCurso) {
642
- setCursos((prev) =>
643
- prev.map((c) => (c.id === editingCurso.id ? { ...c, ...data } : c))
644
- );
645
- toast.success(t('toasts.courseUpdated'));
646
- setSaving(false);
608
+ try {
609
+ const payload = {
610
+ slug: data.nomeInterno.trim(),
611
+ title: data.tituloComercial,
612
+ description: data.descricao,
613
+ level: toApiLevel(data.nivel),
614
+ status: toApiStatus(data.status),
615
+ categorySlugs: data.categorias,
616
+ primaryColor: data.primaryColor,
617
+ primaryContrastColor: getContrastColor(data.primaryColor),
618
+ secondaryColor: data.secondaryColor,
619
+ secondaryContrastColor: getContrastColor(data.secondaryColor),
620
+ };
621
+
622
+ if (editingCurso) {
623
+ await request({
624
+ url: `/lms/courses/${editingCurso.id}`,
625
+ method: 'PATCH',
626
+ data: payload,
627
+ });
628
+ toast.success(t('toasts.courseUpdated'));
629
+ } else {
630
+ const response = await request<ApiCourse>({
631
+ url: '/lms/courses',
632
+ method: 'POST',
633
+ data: payload,
634
+ });
635
+
636
+ toast.success(t('toasts.courseCreated'));
637
+ if (response.data?.id) {
638
+ setTimeout(
639
+ () => router.push(`/lms/courses/${response.data.id}`),
640
+ 400
641
+ );
642
+ }
643
+ }
644
+
645
+ await refetchCourses();
647
646
  setSheetOpen(false);
648
- } else {
649
- const newId = Date.now();
650
- setCursos((prev) => [
651
- {
652
- id: newId,
653
- ...data,
654
- alunosInscritos: 0,
655
- criadoEm: new Date().toISOString().split('T')[0] ?? '',
656
- },
657
- ...prev,
658
- ]);
659
- toast.success(t('toasts.courseCreated'));
647
+ } finally {
660
648
  setSaving(false);
661
- setSheetOpen(false);
662
- setTimeout(() => router.push(`/lms/courses/${newId}`), 400);
663
649
  }
664
650
  }
665
651
 
666
- function confirmDelete() {
652
+ async function confirmDelete() {
667
653
  if (!cursoToDelete) return;
668
- setCursos((prev) => prev.filter((c) => c.id !== cursoToDelete.id));
654
+ await request({
655
+ url: `/lms/courses/${cursoToDelete.id}`,
656
+ method: 'DELETE',
657
+ });
669
658
  setSelectedIds((prev) => {
670
659
  const n = new Set(prev);
671
660
  n.delete(cursoToDelete.id);
@@ -676,53 +665,69 @@ export default function CursosPage() {
676
665
  );
677
666
  setCursoToDelete(null);
678
667
  setDeleteDialogOpen(false);
668
+ await refetchCourses();
679
669
  }
680
670
 
681
671
  // ── KPI counts ────────────────────────────────────────────────────────────
682
672
 
683
- const totalAlunos = cursos.reduce((a, c) => a + c.alunosInscritos, 0);
684
- const countAtivos = cursos.filter((c) => c.status === 'ativo').length;
685
- const countRascunhos = cursos.filter((c) => c.status === 'rascunho').length;
686
- const countDestaque = cursos.filter((c) => c.destaque).length;
673
+ const totalAlunos = statsData?.totalStudents ?? 0;
674
+ const countAtivos = statsData?.publishedCourses ?? 0;
675
+ const countDestaque = statsData?.featuredCourses ?? 0;
687
676
 
688
677
  const kpis = [
689
678
  {
690
- label: t('kpis.totalCourses.label'),
691
- valor: cursos.length,
692
- sub: t('kpis.totalCourses.sub'),
679
+ key: 'total-courses',
680
+ title: t('kpis.totalCourses.label'),
681
+ value: statsData?.totalCourses ?? effectiveCourseList?.total ?? 0,
682
+ description: t('kpis.totalCourses.sub'),
693
683
  icon: BookOpen,
694
- iconBg: 'bg-orange-100',
695
- iconColor: 'text-orange-600',
684
+ layout: 'compact' as const,
685
+ accentClassName: 'from-orange-500/25 via-amber-500/15 to-transparent',
686
+ iconContainerClassName: 'bg-orange-100 text-orange-700',
696
687
  },
697
688
  {
698
- label: t('kpis.activeCourses.label'),
699
- valor: countAtivos,
700
- sub: t('kpis.activeCourses.sub'),
689
+ key: 'active-courses',
690
+ title: t('kpis.activeCourses.label'),
691
+ value: countAtivos,
692
+ description: t('kpis.activeCourses.sub'),
701
693
  icon: TrendingUp,
702
- iconBg: 'bg-muted',
703
- iconColor: 'text-foreground',
694
+ layout: 'compact' as const,
695
+ accentClassName: 'from-sky-500/25 via-blue-500/15 to-transparent',
696
+ iconContainerClassName: 'bg-sky-100 text-sky-700',
704
697
  },
705
698
  {
706
- label: t('kpis.totalStudents.label'),
707
- valor: totalAlunos.toLocaleString('pt-BR'),
708
- sub: t('kpis.totalStudents.sub'),
699
+ key: 'total-students',
700
+ title: t('kpis.totalStudents.label'),
701
+ value: totalAlunos.toLocaleString('pt-BR'),
702
+ description: t('kpis.totalStudents.sub'),
709
703
  icon: Users,
710
- iconBg: 'bg-muted',
711
- iconColor: 'text-foreground',
704
+ layout: 'compact' as const,
705
+ accentClassName: 'from-emerald-500/25 via-green-500/15 to-transparent',
706
+ iconContainerClassName: 'bg-emerald-100 text-emerald-700',
712
707
  },
713
708
  {
714
- label: t('kpis.featured.label'),
715
- valor: countDestaque,
716
- sub: t('kpis.featured.sub'),
709
+ key: 'featured-courses',
710
+ title: t('form.flags.featured.label'),
711
+ value: countDestaque,
717
712
  icon: Star,
718
- iconBg: 'bg-muted',
719
- iconColor: 'text-foreground',
713
+ layout: 'compact' as const,
714
+ accentClassName: 'from-amber-500/25 via-yellow-500/15 to-transparent',
715
+ iconContainerClassName: 'bg-amber-100 text-amber-700',
720
716
  },
721
717
  ];
722
718
 
723
- const allPageSelected =
724
- paginatedCursos.length > 0 &&
725
- paginatedCursos.every((c) => selectedIds.has(c.id));
719
+ const { locales } = useApp();
720
+
721
+ const handleNewCourse = (): void => {
722
+ const nextLocaleData: Record<string, { name: string }> = {};
723
+ locales.forEach((locale: Locale) => {
724
+ nextLocaleData[locale.code] = {
725
+ name: '',
726
+ };
727
+ });
728
+ void nextLocaleData;
729
+ openCreateSheet();
730
+ };
726
731
 
727
732
  // ── Render ────────────────────────────────────────────────────────────────
728
733
 
@@ -740,147 +745,133 @@ export default function CursosPage() {
740
745
  label: t('breadcrumbs.courses'),
741
746
  },
742
747
  ]}
743
- actions={
744
- <Button onClick={openCreateSheet} className="gap-2">
745
- <Plus className="size-4" />
746
- {t('actions.createCourse')}
747
- </Button>
748
- }
748
+ actions={[
749
+ {
750
+ label: t('actions.createCourse'),
751
+ onClick: () => handleNewCourse(),
752
+ variant: 'default',
753
+ },
754
+ ]}
749
755
  />
750
756
 
751
- {/* ── KPI Cards ────────────────────────────────────────────────────── */}
752
- <div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
753
- {loading
754
- ? Array.from({ length: 4 }).map((_, i) => (
755
- <Card key={i}>
756
- <CardContent className="p-4">
757
- <Skeleton className="mb-2 h-8 w-16" />
758
- <Skeleton className="h-4 w-28" />
759
- </CardContent>
760
- </Card>
761
- ))
762
- : kpis.map((kpi, i) => (
763
- <motion.div
764
- key={kpi.label}
765
- initial={{ opacity: 0, y: 12 }}
766
- animate={{ opacity: 1, y: 0 }}
767
- transition={{ delay: i * 0.07 }}
768
- >
769
- <Card className="overflow-hidden">
770
- <CardContent className="flex items-start justify-between p-5">
771
- <div>
772
- <p className="text-sm text-muted-foreground">
773
- {kpi.label}
774
- </p>
775
- <p className="mt-1 text-3xl font-bold tracking-tight">
776
- {kpi.valor}
777
- </p>
778
- <p className="mt-0.5 text-xs text-muted-foreground">
779
- {kpi.sub}
780
- </p>
781
- </div>
782
- <div
783
- className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
784
- >
785
- <kpi.icon className={`size-5 ${kpi.iconColor}`} />
786
- </div>
757
+ <motion.div
758
+ className="space-y-4"
759
+ variants={stagger}
760
+ initial="hidden"
761
+ animate="show"
762
+ >
763
+ <motion.div variants={fadeUp}>
764
+ {initialLoading && !statsData ? (
765
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
766
+ {Array.from({ length: 4 }).map((_, i) => (
767
+ <Card key={i}>
768
+ <CardContent className="p-4">
769
+ <Skeleton className="mb-2 h-8 w-16" />
770
+ <Skeleton className="h-4 w-28" />
787
771
  </CardContent>
788
772
  </Card>
789
- </motion.div>
790
- ))}
791
- </div>
792
-
793
- {/* ── Search bar ───────────────────────────────────────────────────── */}
794
- <form onSubmit={handleSearch} className="mb-6 mt-0">
795
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
796
- {/* Search input — grows to fill space */}
797
- <div className="relative flex-1">
798
- <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
799
- <Input
800
- placeholder={t('filters.searchPlaceholder')}
801
- value={buscaInput}
802
- onChange={(e) => setBuscaInput(e.target.value)}
803
- className="pl-9"
804
- />
805
- </div>
773
+ ))}
774
+ </div>
775
+ ) : (
776
+ <KpiCardsGrid items={kpis} />
777
+ )}
778
+ </motion.div>
806
779
 
807
- {/* Filters — inline, right of search */}
808
- <div className="flex flex-wrap items-center gap-2">
809
- <Select
810
- value={filtroStatusInput}
811
- onValueChange={setFiltroStatusInput}
812
- >
813
- <SelectTrigger className="h-9 w-[130px] text-sm">
814
- <SelectValue placeholder={t('table.headers.status')} />
815
- </SelectTrigger>
816
- <SelectContent>
817
- <SelectItem value="todos">
818
- {t('filters.allStatuses')}
819
- </SelectItem>
820
- <SelectItem value="ativo">{t('status.active')}</SelectItem>
821
- <SelectItem value="rascunho">{t('status.draft')}</SelectItem>
822
- <SelectItem value="arquivado">
823
- {t('status.archived')}
824
- </SelectItem>
825
- </SelectContent>
826
- </Select>
827
-
828
- <Select
829
- value={filtroNivelInput}
830
- onValueChange={setFiltroNivelInput}
831
- >
832
- <SelectTrigger className="h-9 w-[130px] text-sm">
833
- <SelectValue placeholder={t('table.headers.level')} />
834
- </SelectTrigger>
835
- <SelectContent>
836
- <SelectItem value="todos">{t('filters.allLevels')}</SelectItem>
837
- <SelectItem value="iniciante">
838
- {t('levels.beginner')}
839
- </SelectItem>
840
- <SelectItem value="intermediario">
841
- {t('levels.intermediate')}
842
- </SelectItem>
843
- <SelectItem value="avancado">{t('levels.advanced')}</SelectItem>
844
- </SelectContent>
845
- </Select>
846
-
847
- <Select value={filtroCatInput} onValueChange={setFiltroCatInput}>
848
- <SelectTrigger className="h-9 w-[130px] text-sm">
849
- <SelectValue placeholder={t('table.headers.categories')} />
850
- </SelectTrigger>
851
- <SelectContent>
852
- <SelectItem value="todos">
853
- {t('filters.allCategories')}
854
- </SelectItem>
855
- {CATEGORIAS.map((c) => (
856
- <SelectItem key={c} value={c}>
857
- {c}
858
- </SelectItem>
859
- ))}
860
- </SelectContent>
861
- </Select>
862
-
863
- {hasActiveFilters && (
864
- <Button
865
- type="button"
866
- variant="ghost"
867
- size="sm"
868
- onClick={clearFilters}
869
- className="h-9 text-muted-foreground"
870
- >
871
- <X className="mr-1 size-3.5" />
872
- {t('filters.clear')}
873
- </Button>
874
- )}
875
-
876
- {/* Submit button */}
877
- <Button type="submit" size="sm" className="h-9 gap-2">
878
- <Search className="size-3.5" />
879
- {t('filters.search')}
880
- </Button>
780
+ <motion.div
781
+ variants={fadeUp}
782
+ className="space-y-3 md:flex flex flex-col"
783
+ >
784
+ <SearchBar
785
+ searchQuery={buscaInput}
786
+ onSearchChange={setBuscaInput}
787
+ onSearch={() => {
788
+ setDebouncedBuscaInput(buscaInput.trim());
789
+ setCurrentPage(1);
790
+ }}
791
+ placeholder={t('filters.searchPlaceholder')}
792
+ controls={[
793
+ {
794
+ id: 'status',
795
+ type: 'select',
796
+ value: filtroStatusInput,
797
+ onChange: (value) =>
798
+ setFiltroStatusInput(
799
+ value === 'todos' ? value : toPtStatus(value)
800
+ ),
801
+ placeholder: t('table.headers.status'),
802
+ options: [
803
+ { value: 'todos', label: t('filters.allStatuses') },
804
+ { value: 'ativo', label: t('status.active') },
805
+ { value: 'rascunho', label: t('status.draft') },
806
+ { value: 'arquivado', label: t('status.archived') },
807
+ ],
808
+ },
809
+ {
810
+ id: 'level',
811
+ type: 'select',
812
+ value: filtroNivelInput,
813
+ onChange: (value) =>
814
+ setFiltroNivelInput(
815
+ value === 'todos' ? value : toPtLevel(value)
816
+ ),
817
+ placeholder: t('table.headers.level'),
818
+ options: [
819
+ { value: 'todos', label: t('filters.allLevels') },
820
+ { value: 'iniciante', label: t('levels.beginner') },
821
+ {
822
+ value: 'intermediario',
823
+ label: t('levels.intermediate'),
824
+ },
825
+ { value: 'avancado', label: t('levels.advanced') },
826
+ ],
827
+ },
828
+ {
829
+ id: 'category',
830
+ type: 'select',
831
+ value: filtroCatInput,
832
+ onChange: setFiltroCatInput,
833
+ placeholder: t('table.headers.categories'),
834
+ options: [
835
+ { value: 'todos', label: t('filters.allCategories') },
836
+ ...categoryOptions,
837
+ ],
838
+ },
839
+ ]}
840
+ afterSearchButton={
841
+ <ViewModeToggle
842
+ viewMode={viewMode}
843
+ onViewModeChange={setViewMode}
844
+ listLabel={t('viewMode.list')}
845
+ cardsLabel={t('viewMode.cards')}
846
+ />
847
+ }
848
+ />
849
+
850
+ <div className="flex flex-wrap items-center justify-between gap-3">
851
+ <p className="text-sm text-muted-foreground">
852
+ {effectiveCourseList?.total ?? 0}{' '}
853
+ {(effectiveCourseList?.total ?? 0) === 1
854
+ ? t('pagination.course')
855
+ : t('pagination.courses')}
856
+ </p>
857
+ <div className="flex items-center gap-2">
858
+ {hasActiveFilters && (
859
+ <Button
860
+ type="button"
861
+ variant="ghost"
862
+ size="sm"
863
+ onClick={clearFilters}
864
+ >
865
+ {t('filters.clear')}
866
+ </Button>
867
+ )}
868
+ {cardsRefreshing && (
869
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
870
+ )}
871
+ </div>
881
872
  </div>
882
- </div>
883
- </form>
873
+ </motion.div>
874
+ </motion.div>
884
875
 
885
876
  {/* ── Bulk action bar ───────────────────────────────────────────────── */}
886
877
  <AnimatePresence>
@@ -918,11 +909,17 @@ export default function CursosPage() {
918
909
  variant="secondary"
919
910
  className="gap-1.5 text-destructive hover:text-destructive"
920
911
  onClick={() => {
921
- setCursos((p) => p.filter((c) => !selectedIds.has(c.id)));
922
- toast.success(
923
- t('toasts.coursesRemoved', { count: selectedIds.size })
924
- );
925
- setSelectedIds(new Set());
912
+ Promise.all(
913
+ Array.from(selectedIds).map((id) =>
914
+ request({ url: `/lms/courses/${id}`, method: 'DELETE' })
915
+ )
916
+ ).then(async () => {
917
+ toast.success(
918
+ t('toasts.coursesRemoved', { count: selectedIds.size })
919
+ );
920
+ setSelectedIds(new Set());
921
+ await refetchCourses();
922
+ });
926
923
  }}
927
924
  >
928
925
  <Trash2 className="size-3.5" /> {t('bulkActions.delete')}
@@ -941,93 +938,379 @@ export default function CursosPage() {
941
938
  )}
942
939
  </AnimatePresence>
943
940
 
944
- {/* ── Cards grid ───────────────────────────────────────────────────── */}
945
- {loading ? (
946
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
947
- {Array.from({ length: 6 }).map((_, i) => (
948
- <Card key={i} className="overflow-hidden">
949
- <CardContent className="p-5">
950
- <div className="mb-3 flex items-start justify-between">
951
- <Skeleton className="h-5 w-20 rounded-full" />
952
- <Skeleton className="size-8 rounded-md" />
953
- </div>
954
- <Skeleton className="mb-1.5 h-5 w-3/4" />
955
- <Skeleton className="mb-4 h-4 w-full" />
956
- <Skeleton className="mb-4 h-4 w-2/3" />
957
- <div className="flex gap-2">
958
- <Skeleton className="h-6 w-16 rounded-full" />
959
- <Skeleton className="h-6 w-20 rounded-full" />
960
- </div>
961
- </CardContent>
962
- </Card>
963
- ))}
964
- </div>
965
- ) : filteredCursos.length === 0 ? (
941
+ {/* ── Course list ───────────────────────────────────────────────────── */}
942
+ {initialLoading ? (
943
+ viewMode === 'cards' ? (
944
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
945
+ {Array.from({ length: 6 }).map((_, i) => (
946
+ <Card key={i} className="overflow-hidden">
947
+ <CardContent className="p-5">
948
+ <div className="mb-3 flex items-start justify-between">
949
+ <Skeleton className="h-5 w-20 rounded-full" />
950
+ <Skeleton className="size-8 rounded-md" />
951
+ </div>
952
+ <Skeleton className="mb-1.5 h-5 w-3/4" />
953
+ <Skeleton className="mb-4 h-4 w-full" />
954
+ <Skeleton className="mb-4 h-4 w-2/3" />
955
+ <div className="flex gap-2">
956
+ <Skeleton className="h-6 w-16 rounded-full" />
957
+ <Skeleton className="h-6 w-20 rounded-full" />
958
+ </div>
959
+ </CardContent>
960
+ </Card>
961
+ ))}
962
+ </div>
963
+ ) : (
964
+ <div className="overflow-hidden rounded-xl border border-border/70">
965
+ <Table>
966
+ <TableHeader>
967
+ <TableRow>
968
+ <TableHead className="w-10" />
969
+ <TableHead>{t('table.headers.name')}</TableHead>
970
+ <TableHead>{t('table.headers.level')}</TableHead>
971
+ <TableHead>{t('table.headers.status')}</TableHead>
972
+ <TableHead>{t('table.headers.categories')}</TableHead>
973
+ <TableHead className="text-right">
974
+ {t('cards.studentsLabel')}
975
+ </TableHead>
976
+ <TableHead className="w-12" />
977
+ </TableRow>
978
+ </TableHeader>
979
+ <TableBody>
980
+ {Array.from({ length: 6 }).map((_, i) => (
981
+ <TableRow key={i}>
982
+ <TableCell>
983
+ <Skeleton className="size-4 rounded-sm" />
984
+ </TableCell>
985
+ <TableCell>
986
+ <div className="space-y-1.5">
987
+ <Skeleton className="h-4 w-44" />
988
+ <Skeleton className="h-3 w-28" />
989
+ </div>
990
+ </TableCell>
991
+ <TableCell>
992
+ <Skeleton className="h-5 w-20 rounded-full" />
993
+ </TableCell>
994
+ <TableCell>
995
+ <Skeleton className="h-5 w-20 rounded-full" />
996
+ </TableCell>
997
+ <TableCell>
998
+ <Skeleton className="h-5 w-28 rounded-full" />
999
+ </TableCell>
1000
+ <TableCell className="text-right">
1001
+ <Skeleton className="ml-auto h-4 w-12" />
1002
+ </TableCell>
1003
+ <TableCell>
1004
+ <Skeleton className="ml-auto size-8 rounded-md" />
1005
+ </TableCell>
1006
+ </TableRow>
1007
+ ))}
1008
+ </TableBody>
1009
+ </Table>
1010
+ </div>
1011
+ )
1012
+ ) : (effectiveCourseList?.total ?? 0) === 0 ? (
966
1013
  <EmptyState
967
- icon={<BookOpen className="h-12 w-12" />}
1014
+ icon={<BookOpen className="size-12 text-muted-foreground/40" />}
968
1015
  title={t('table.empty.title')}
969
1016
  description={t('table.empty.description')}
970
1017
  actionLabel={t('actions.createCourse')}
1018
+ actionIcon={<Plus className="mr-2 size-4" />}
971
1019
  onAction={openCreateSheet}
972
- actionIcon={<Plus className="mr-2 h-4 w-4" />}
1020
+ className="py-20"
973
1021
  />
974
1022
  ) : (
975
- <motion.div
976
- className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
977
- variants={stagger}
978
- initial="hidden"
979
- animate="show"
980
- >
981
- {paginatedCursos.map((curso) => {
982
- const nivelColor = NIVEL_COLOR[curso.nivel];
983
- const statusVariant = STATUS_VARIANT[curso.status];
984
- const isSelected = selectedIds.has(curso.id);
985
-
986
- return (
987
- <motion.div key={curso.id} variants={fadeUp}>
988
- <Card
989
- className={`group relative cursor-pointer overflow-hidden border-0 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-1 ${isSelected ? 'ring-2 ring-primary ring-offset-2' : ''}`}
990
- onClick={() => handleCardClick(curso)}
991
- title={t('cards.tooltip')}
992
- >
993
- {/* Top accent */}
994
- <div className="h-1 w-full bg-foreground" />
995
-
996
- {/* Selection checkbox */}
997
- <div
998
- className="absolute left-4 top-5 z-10 opacity-0 transition-opacity group-hover:opacity-100 data-[selected=true]:opacity-100"
999
- data-selected={isSelected}
1023
+ <div className="relative">
1024
+ {cardsRefreshing && (
1025
+ <div className="absolute inset-0 z-10 rounded-2xl bg-background/55 backdrop-blur-[1px]" />
1026
+ )}
1027
+ {viewMode === 'cards' ? (
1028
+ <motion.div
1029
+ className={`grid gap-4 sm:grid-cols-2 lg:grid-cols-3 ${cardsRefreshing ? 'pointer-events-none' : ''}`}
1030
+ variants={stagger}
1031
+ initial="hidden"
1032
+ animate="show"
1033
+ >
1034
+ {paginatedCursos.map((curso) => {
1035
+ const nivelColor = NIVEL_COLOR[curso.nivel];
1036
+ const statusVariant = STATUS_VARIANT[curso.status];
1037
+ const isSelected = selectedIds.has(curso.id);
1038
+
1039
+ return (
1040
+ <motion.div
1041
+ key={curso.id}
1042
+ variants={fadeUp}
1043
+ className="h-full"
1000
1044
  >
1001
- <Checkbox
1002
- checked={isSelected}
1003
- onCheckedChange={() => {}}
1004
- onClick={(e) => toggleSelect(curso.id, e)}
1005
- aria-label={t('table.selectCourse', {
1006
- title: curso.tituloComercial,
1007
- })}
1008
- className="border-2 bg-background shadow-sm"
1009
- />
1010
- </div>
1045
+ <Card
1046
+ className={`group relative flex h-full cursor-pointer flex-col overflow-hidden border-border/70 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md ${isSelected ? 'ring-2 ring-primary ring-offset-2' : ''}`}
1047
+ onClick={() => handleCardClick(curso)}
1048
+ title={t('cards.tooltip')}
1049
+ >
1050
+ <div
1051
+ className="absolute inset-x-0 top-0 h-1"
1052
+ style={{
1053
+ backgroundColor: curso.primaryColor || '#1D4ED8',
1054
+ }}
1055
+ />
1011
1056
 
1012
- <CardContent className="p-5">
1013
- {/* Header with Logo + Title + Actions */}
1014
- <div className="mb-4 flex items-start gap-4">
1015
- {/* Logo placeholder */}
1016
- <div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-muted border">
1017
- <BookOpen className="size-7 text-foreground" />
1057
+ <div
1058
+ className="absolute left-4 top-5 z-10 opacity-0 transition-opacity group-hover:opacity-100 data-[selected=true]:opacity-100"
1059
+ data-selected={isSelected}
1060
+ >
1061
+ <Checkbox
1062
+ checked={isSelected}
1063
+ onCheckedChange={() => {}}
1064
+ onClick={(e) => toggleSelect(curso.id, e)}
1065
+ aria-label={t('table.selectCourse', {
1066
+ title: curso.tituloComercial,
1067
+ })}
1068
+ className="border-2 bg-background shadow-sm"
1069
+ />
1018
1070
  </div>
1019
- <div className="min-w-0 flex-1">
1020
- <div className="mb-1.5 flex items-start justify-between gap-2">
1021
- <h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
1022
- {curso.tituloComercial}
1023
- </h3>
1071
+
1072
+ <CardContent className="flex h-full flex-col p-4">
1073
+ <div className="mb-3 flex items-start gap-3">
1074
+ <div className="flex size-12 shrink-0 items-center justify-center rounded-xl border bg-muted/60">
1075
+ <BookOpen className="size-6 text-foreground" />
1076
+ </div>
1077
+ <div className="min-w-0 flex-1">
1078
+ <div className="mb-1.5 flex items-start justify-between gap-2">
1079
+ <h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
1080
+ {curso.tituloComercial}
1081
+ </h3>
1082
+ <DropdownMenu>
1083
+ <DropdownMenuTrigger asChild>
1084
+ <Button
1085
+ variant="ghost"
1086
+ size="icon"
1087
+ className="size-8 shrink-0 -mr-2 -mt-1"
1088
+ onClick={(e) => e.stopPropagation()}
1089
+ aria-label={t('table.actions.label')}
1090
+ >
1091
+ <MoreHorizontal className="size-4" />
1092
+ </Button>
1093
+ </DropdownMenuTrigger>
1094
+ <DropdownMenuContent
1095
+ align="end"
1096
+ className="w-48"
1097
+ >
1098
+ <DropdownMenuItem
1099
+ onClick={(e) => {
1100
+ e.stopPropagation();
1101
+ router.push(`/lms/courses/${curso.id}`);
1102
+ }}
1103
+ >
1104
+ <Eye className="mr-2 size-4" />{' '}
1105
+ {t('table.actions.viewDetails')}
1106
+ </DropdownMenuItem>
1107
+ <DropdownMenuItem
1108
+ onClick={(e) => openEditSheet(curso, e)}
1109
+ >
1110
+ <Pencil className="mr-2 size-4" />{' '}
1111
+ {t('table.actions.edit')}
1112
+ </DropdownMenuItem>
1113
+ <DropdownMenuSeparator />
1114
+ <DropdownMenuItem
1115
+ className="text-destructive focus:text-destructive"
1116
+ onClick={(e) => openDeleteDialog(curso, e)}
1117
+ >
1118
+ <Trash2 className="mr-2 size-4" />{' '}
1119
+ {t('table.actions.delete')}
1120
+ </DropdownMenuItem>
1121
+ </DropdownMenuContent>
1122
+ </DropdownMenu>
1123
+ </div>
1124
+ <p className="text-xs text-muted-foreground">
1125
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
1126
+ {curso.codigo}
1127
+ </code>
1128
+ <span className="mx-1.5 text-muted-foreground/50">
1129
+ |
1130
+ </span>
1131
+ <span>{curso.nomeInterno}</span>
1132
+ </p>
1133
+ </div>
1134
+ </div>
1135
+
1136
+ <div className="mb-3 flex flex-wrap items-center gap-1.5">
1137
+ <span
1138
+ className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${nivelColor}`}
1139
+ >
1140
+ {getLevelLabel(curso)}
1141
+ </span>
1142
+ <Badge
1143
+ variant={statusVariant}
1144
+ className="text-[11px]"
1145
+ >
1146
+ {getStatusLabel(curso)}
1147
+ </Badge>
1148
+ {curso.destaque && (
1149
+ <span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-[11px] font-medium text-amber-700">
1150
+ <Star className="size-3 fill-amber-400 text-amber-400" />{' '}
1151
+ {t('form.flags.featured.label')}
1152
+ </span>
1153
+ )}
1154
+ </div>
1155
+
1156
+ <p className="mb-4 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
1157
+ {curso.descricao}
1158
+ </p>
1159
+
1160
+ <div className="mb-4 flex flex-wrap gap-1.5">
1161
+ {curso.categorias.slice(0, 3).map((cat) => (
1162
+ <span
1163
+ key={cat}
1164
+ className="rounded-md bg-muted/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
1165
+ >
1166
+ {categoryLabelBySlug[cat] || cat}
1167
+ </span>
1168
+ ))}
1169
+ {curso.categorias.length > 3 && (
1170
+ <span className="rounded-md bg-muted/60 px-2 py-0.5 text-[11px] text-muted-foreground">
1171
+ +{curso.categorias.length - 3}
1172
+ </span>
1173
+ )}
1174
+ </div>
1175
+
1176
+ <div className="mt-auto flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5">
1177
+ <div className="flex items-center gap-1.5">
1178
+ <Users className="size-4 text-muted-foreground" />
1179
+ <span className="text-sm font-medium">
1180
+ {curso.alunosInscritos.toLocaleString('pt-BR')}
1181
+ </span>
1182
+ <span className="text-xs text-muted-foreground">
1183
+ {t('cards.studentsLabel')}
1184
+ </span>
1185
+ </div>
1186
+ {curso.certificado && (
1187
+ <div className="flex items-center gap-1.5">
1188
+ <Award className="size-4 text-foreground" />
1189
+ <span className="text-xs font-medium">
1190
+ {t('form.flags.certificate.label')}
1191
+ </span>
1192
+ </div>
1193
+ )}
1194
+ </div>
1195
+ </CardContent>
1196
+ </Card>
1197
+ </motion.div>
1198
+ );
1199
+ })}
1200
+ </motion.div>
1201
+ ) : (
1202
+ <div
1203
+ className={`overflow-hidden rounded-xl border border-border/70 ${cardsRefreshing ? 'pointer-events-none' : ''}`}
1204
+ >
1205
+ <Table>
1206
+ <TableHeader>
1207
+ <TableRow>
1208
+ <TableHead className="w-10" />
1209
+ <TableHead>{t('table.headers.name')}</TableHead>
1210
+ <TableHead>{t('table.headers.level')}</TableHead>
1211
+ <TableHead>{t('table.headers.status')}</TableHead>
1212
+ <TableHead>{t('table.headers.categories')}</TableHead>
1213
+ <TableHead className="text-right">
1214
+ {t('cards.studentsLabel')}
1215
+ </TableHead>
1216
+ <TableHead className="w-12" />
1217
+ </TableRow>
1218
+ </TableHeader>
1219
+ <TableBody>
1220
+ {paginatedCursos.map((curso) => {
1221
+ const nivelColor = NIVEL_COLOR[curso.nivel];
1222
+ const statusVariant = STATUS_VARIANT[curso.status];
1223
+ const isSelected = selectedIds.has(curso.id);
1224
+
1225
+ return (
1226
+ <TableRow
1227
+ key={curso.id}
1228
+ className="cursor-pointer"
1229
+ data-state={isSelected ? 'selected' : undefined}
1230
+ onClick={() => handleCardClick(curso)}
1231
+ title={t('cards.tooltip')}
1232
+ >
1233
+ <TableCell onClick={(e) => e.stopPropagation()}>
1234
+ <Checkbox
1235
+ checked={isSelected}
1236
+ onCheckedChange={() => {}}
1237
+ onClick={(e) => toggleSelect(curso.id, e)}
1238
+ aria-label={t('table.selectCourse', {
1239
+ title: curso.tituloComercial,
1240
+ })}
1241
+ className="border-2 bg-background shadow-sm"
1242
+ />
1243
+ </TableCell>
1244
+ <TableCell>
1245
+ <div className="min-w-0">
1246
+ <p className="truncate font-semibold text-foreground">
1247
+ {curso.tituloComercial}
1248
+ </p>
1249
+ <p className="mt-1 truncate text-xs text-muted-foreground">
1250
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
1251
+ {curso.codigo}
1252
+ </code>
1253
+ <span className="mx-1.5 text-muted-foreground/50">
1254
+ |
1255
+ </span>
1256
+ <span>{curso.nomeInterno}</span>
1257
+ </p>
1258
+ </div>
1259
+ </TableCell>
1260
+ <TableCell>
1261
+ <span
1262
+ className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${nivelColor}`}
1263
+ >
1264
+ {getLevelLabel(curso)}
1265
+ </span>
1266
+ </TableCell>
1267
+ <TableCell>
1268
+ <div className="flex flex-wrap items-center gap-1.5">
1269
+ <Badge
1270
+ variant={statusVariant}
1271
+ className="text-[11px]"
1272
+ >
1273
+ {getStatusLabel(curso)}
1274
+ </Badge>
1275
+ {curso.destaque && (
1276
+ <span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
1277
+ <Star className="size-3 fill-amber-400 text-amber-400" />
1278
+ </span>
1279
+ )}
1280
+ {curso.certificado && (
1281
+ <span className="inline-flex items-center gap-1 rounded-full border bg-muted/70 px-2 py-0.5 text-[11px] font-medium text-foreground">
1282
+ <Award className="size-3.5" />
1283
+ </span>
1284
+ )}
1285
+ </div>
1286
+ </TableCell>
1287
+ <TableCell>
1288
+ <div className="flex flex-wrap gap-1">
1289
+ {curso.categorias.slice(0, 2).map((cat) => (
1290
+ <span
1291
+ key={cat}
1292
+ className="rounded-md bg-muted/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
1293
+ >
1294
+ {categoryLabelBySlug[cat] || cat}
1295
+ </span>
1296
+ ))}
1297
+ {curso.categorias.length > 2 && (
1298
+ <span className="rounded-md bg-muted/60 px-2 py-0.5 text-[11px] text-muted-foreground">
1299
+ +{curso.categorias.length - 2}
1300
+ </span>
1301
+ )}
1302
+ </div>
1303
+ </TableCell>
1304
+ <TableCell className="text-right font-medium">
1305
+ {curso.alunosInscritos.toLocaleString('pt-BR')}
1306
+ </TableCell>
1307
+ <TableCell onClick={(e) => e.stopPropagation()}>
1024
1308
  <DropdownMenu>
1025
1309
  <DropdownMenuTrigger asChild>
1026
1310
  <Button
1027
1311
  variant="ghost"
1028
1312
  size="icon"
1029
- className="size-8 shrink-0 -mr-2 -mt-1"
1030
- onClick={(e) => e.stopPropagation()}
1313
+ className="ml-auto size-8"
1031
1314
  aria-label={t('table.actions.label')}
1032
1315
  >
1033
1316
  <MoreHorizontal className="size-4" />
@@ -1035,16 +1318,15 @@ export default function CursosPage() {
1035
1318
  </DropdownMenuTrigger>
1036
1319
  <DropdownMenuContent align="end" className="w-48">
1037
1320
  <DropdownMenuItem
1038
- onClick={(e) => {
1039
- e.stopPropagation();
1040
- router.push(`/lms/courses/${curso.id}`);
1041
- }}
1321
+ onClick={() =>
1322
+ router.push(`/lms/courses/${curso.id}`)
1323
+ }
1042
1324
  >
1043
1325
  <Eye className="mr-2 size-4" />{' '}
1044
1326
  {t('table.actions.viewDetails')}
1045
1327
  </DropdownMenuItem>
1046
1328
  <DropdownMenuItem
1047
- onClick={(e) => openEditSheet(curso, e)}
1329
+ onClick={() => openEditSheet(curso)}
1048
1330
  >
1049
1331
  <Pencil className="mr-2 size-4" />{' '}
1050
1332
  {t('table.actions.edit')}
@@ -1052,365 +1334,59 @@ export default function CursosPage() {
1052
1334
  <DropdownMenuSeparator />
1053
1335
  <DropdownMenuItem
1054
1336
  className="text-destructive focus:text-destructive"
1055
- onClick={(e) => {
1056
- e.stopPropagation();
1057
- setCursoToDelete(curso);
1058
- setDeleteDialogOpen(true);
1059
- }}
1337
+ onClick={(e) => openDeleteDialog(curso, e)}
1060
1338
  >
1061
1339
  <Trash2 className="mr-2 size-4" />{' '}
1062
1340
  {t('table.actions.delete')}
1063
1341
  </DropdownMenuItem>
1064
1342
  </DropdownMenuContent>
1065
1343
  </DropdownMenu>
1066
- </div>
1067
- <p className="text-xs text-muted-foreground">
1068
- <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
1069
- {curso.codigo}
1070
- </code>
1071
- <span className="mx-1.5 text-muted-foreground/50">
1072
- |
1073
- </span>
1074
- <span>{curso.nomeInterno}</span>
1075
- </p>
1076
- </div>
1077
- </div>
1078
-
1079
- {/* Badges row */}
1080
- <div className="mb-3 flex flex-wrap items-center gap-1.5 pl-[72px]">
1081
- <span
1082
- className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${nivelColor}`}
1083
- >
1084
- {t(
1085
- `levels.${curso.nivel === 'iniciante' ? 'beginner' : curso.nivel === 'intermediario' ? 'intermediate' : 'advanced'}`
1086
- )}
1087
- </span>
1088
- <Badge variant={statusVariant} className="text-[11px]">
1089
- {t(
1090
- `status.${curso.status === 'ativo' ? 'active' : curso.status === 'rascunho' ? 'draft' : 'archived'}`
1091
- )}
1092
- </Badge>
1093
- {curso.destaque && (
1094
- <span className="inline-flex items-center gap-1 rounded-full border border-amber-200/60 bg-linear-to-r from-amber-50 to-orange-50 px-2.5 py-0.5 text-[11px] font-medium text-amber-700 shadow-sm">
1095
- <Star className="size-3 fill-amber-400 text-amber-400" />{' '}
1096
- {t('form.flags.featured.label')}
1097
- </span>
1098
- )}
1099
- </div>
1100
-
1101
- {/* Description */}
1102
- <p className="mb-4 line-clamp-2 text-sm text-muted-foreground leading-relaxed">
1103
- {curso.descricao}
1104
- </p>
1105
-
1106
- {/* Categories */}
1107
- <div className="mb-4 flex flex-wrap gap-1.5">
1108
- {curso.categorias.slice(0, 3).map((cat) => (
1109
- <span
1110
- key={cat}
1111
- className="rounded-md bg-muted/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
1112
- >
1113
- {cat}
1114
- </span>
1115
- ))}
1116
- {curso.categorias.length > 3 && (
1117
- <span className="rounded-md bg-muted/60 px-2 py-0.5 text-[11px] text-muted-foreground">
1118
- +{curso.categorias.length - 3}
1119
- </span>
1120
- )}
1121
- </div>
1122
-
1123
- {/* Footer stats */}
1124
- <div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5">
1125
- <div className="flex items-center gap-1.5">
1126
- <Users className="size-4 text-muted-foreground" />
1127
- <span className="text-sm font-medium">
1128
- {curso.alunosInscritos.toLocaleString('pt-BR')}
1129
- </span>
1130
- <span className="text-xs text-muted-foreground">
1131
- {t('cards.studentsLabel')}
1132
- </span>
1133
- </div>
1134
- {curso.certificado && (
1135
- <div className="flex items-center gap-1.5">
1136
- <Award className="size-4 text-foreground" />
1137
- <span className="text-xs font-medium">
1138
- {t('form.flags.certificate.label')}
1139
- </span>
1140
- </div>
1141
- )}
1142
- </div>
1143
- </CardContent>
1144
- </Card>
1145
- </motion.div>
1146
- );
1147
- })}
1148
- </motion.div>
1344
+ </TableCell>
1345
+ </TableRow>
1346
+ );
1347
+ })}
1348
+ </TableBody>
1349
+ </Table>
1350
+ </div>
1351
+ )}
1352
+ </div>
1149
1353
  )}
1150
1354
 
1151
1355
  {/* ── Pagination footer ─────────────────────────────────────────────── */}
1152
- {!loading && filteredCursos.length > 0 && (
1356
+ {!initialLoading && (effectiveCourseList?.total ?? 0) > 0 && (
1153
1357
  <div className="mt-6">
1154
1358
  <PaginationFooter
1155
1359
  currentPage={safePage}
1156
1360
  pageSize={pageSize}
1157
- totalItems={filteredCursos.length}
1361
+ totalItems={effectiveCourseList?.total ?? 0}
1158
1362
  onPageChange={setCurrentPage}
1159
- onPageSizeChange={(nextSize) => {
1160
- setPageSize(nextSize);
1363
+ onPageSizeChange={(nextPageSize) => {
1364
+ setPageSize(nextPageSize);
1161
1365
  setCurrentPage(1);
1162
1366
  }}
1163
- pageSizeOptions={PAGE_SIZES}
1367
+ pageSizeOptions={[6, 12, 24]}
1164
1368
  selectedCount={selectedIds.size}
1165
1369
  />
1166
1370
  </div>
1167
1371
  )}
1168
1372
 
1169
1373
  {/* ── Create / Edit Sheet ────────────────────────────────────────────── */}
1170
- <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
1171
- <SheetContent
1172
- side="right"
1173
- className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1174
- >
1175
- <SheetHeader className="shrink-0">
1176
- <SheetTitle>
1177
- {editingCurso ? t('form.title.edit') : t('form.title.create')}
1178
- </SheetTitle>
1179
- <SheetDescription>
1180
- {editingCurso
1181
- ? t('form.description.edit')
1182
- : t('form.description.create')}
1183
- </SheetDescription>
1184
- </SheetHeader>
1185
-
1186
- <form
1187
- onSubmit={form.handleSubmit(onSubmit)}
1188
- className="flex flex-1 flex-col gap-5 py-6 px-4"
1189
- >
1190
- {/* Codigo */}
1191
- <Field>
1192
- <FieldLabel htmlFor="codigo">
1193
- {t('form.fields.code.label')}{' '}
1194
- <span className="text-destructive">*</span>
1195
- </FieldLabel>
1196
- <Input
1197
- id="codigo"
1198
- placeholder={t('form.fields.code.placeholder')}
1199
- className="uppercase"
1200
- {...form.register('codigo')}
1201
- />
1202
- <FieldError>{form.formState.errors.codigo?.message}</FieldError>
1203
- </Field>
1204
-
1205
- {/* Nome interno */}
1206
- <Field>
1207
- <FieldLabel htmlFor="nomeInterno">
1208
- {t('form.fields.internalName.label')}{' '}
1209
- <span className="text-destructive">*</span>
1210
- </FieldLabel>
1211
- <Input
1212
- id="nomeInterno"
1213
- placeholder={t('form.fields.internalName.placeholder')}
1214
- {...form.register('nomeInterno')}
1215
- />
1216
- <FieldDescription>
1217
- {t('form.fields.internalName.description')}
1218
- </FieldDescription>
1219
- <FieldError>
1220
- {form.formState.errors.nomeInterno?.message}
1221
- </FieldError>
1222
- </Field>
1223
-
1224
- {/* Titulo comercial */}
1225
- <Field>
1226
- <FieldLabel htmlFor="tituloComercial">
1227
- {t('form.fields.commercialTitle.label')}{' '}
1228
- <span className="text-destructive">*</span>
1229
- </FieldLabel>
1230
- <Input
1231
- id="tituloComercial"
1232
- placeholder={t('form.fields.commercialTitle.placeholder')}
1233
- {...form.register('tituloComercial')}
1234
- />
1235
- <FieldError>
1236
- {form.formState.errors.tituloComercial?.message}
1237
- </FieldError>
1238
- </Field>
1239
-
1240
- {/* Descricao */}
1241
- <Field>
1242
- <FieldLabel htmlFor="descricao">
1243
- {t('form.fields.description.label')}{' '}
1244
- <span className="text-destructive">*</span>
1245
- </FieldLabel>
1246
- <Textarea
1247
- id="descricao"
1248
- rows={3}
1249
- placeholder={t('form.fields.description.placeholder')}
1250
- {...form.register('descricao')}
1251
- />
1252
- <FieldError>
1253
- {form.formState.errors.descricao?.message}
1254
- </FieldError>
1255
- </Field>
1256
-
1257
- {/* Nivel + Status */}
1258
- <div className="grid grid-cols-2 gap-4">
1259
- <Field>
1260
- <FieldLabel>
1261
- {t('form.fields.level.label')}{' '}
1262
- <span className="text-destructive">*</span>
1263
- </FieldLabel>
1264
- <Controller
1265
- name="nivel"
1266
- control={form.control}
1267
- render={({ field }) => (
1268
- <Select onValueChange={field.onChange} value={field.value}>
1269
- <SelectTrigger>
1270
- <SelectValue
1271
- placeholder={t('form.fields.level.placeholder')}
1272
- />
1273
- </SelectTrigger>
1274
- <SelectContent>
1275
- <SelectItem value="iniciante">
1276
- {t('levels.beginner')}
1277
- </SelectItem>
1278
- <SelectItem value="intermediario">
1279
- {t('levels.intermediate')}
1280
- </SelectItem>
1281
- <SelectItem value="avancado">
1282
- {t('levels.advanced')}
1283
- </SelectItem>
1284
- </SelectContent>
1285
- </Select>
1286
- )}
1287
- />
1288
- <FieldError>{form.formState.errors.nivel?.message}</FieldError>
1289
- </Field>
1290
- <Field>
1291
- <FieldLabel>
1292
- {t('form.fields.status.label')}{' '}
1293
- <span className="text-destructive">*</span>
1294
- </FieldLabel>
1295
- <Controller
1296
- name="status"
1297
- control={form.control}
1298
- render={({ field }) => (
1299
- <Select onValueChange={field.onChange} value={field.value}>
1300
- <SelectTrigger>
1301
- <SelectValue
1302
- placeholder={t('form.fields.status.placeholder')}
1303
- />
1304
- </SelectTrigger>
1305
- <SelectContent>
1306
- <SelectItem value="ativo">
1307
- {t('status.active')}
1308
- </SelectItem>
1309
- <SelectItem value="rascunho">
1310
- {t('status.draft')}
1311
- </SelectItem>
1312
- <SelectItem value="arquivado">
1313
- {t('status.archived')}
1314
- </SelectItem>
1315
- </SelectContent>
1316
- </Select>
1317
- )}
1318
- />
1319
- <FieldError>{form.formState.errors.status?.message}</FieldError>
1320
- </Field>
1321
- </div>
1322
-
1323
- {/* Categorias */}
1324
- <Field>
1325
- <FieldLabel>
1326
- {t('form.fields.categories.label')}{' '}
1327
- <span className="text-destructive">*</span>
1328
- </FieldLabel>
1329
- <Controller
1330
- name="categorias"
1331
- control={form.control}
1332
- render={({ field }) => (
1333
- <div className="grid grid-cols-2 gap-2">
1334
- {CATEGORIAS.map((cat) => (
1335
- <label
1336
- key={cat}
1337
- className="flex cursor-pointer items-center gap-2 rounded-md border p-2.5 text-sm hover:bg-muted has-checked:border-foreground has-checked:bg-muted"
1338
- >
1339
- <Checkbox
1340
- checked={field.value.includes(cat)}
1341
- onCheckedChange={(checked) => {
1342
- const next = checked
1343
- ? [...field.value, cat]
1344
- : field.value.filter((v) => v !== cat);
1345
- field.onChange(next);
1346
- }}
1347
- />
1348
- {cat}
1349
- </label>
1350
- ))}
1351
- </div>
1352
- )}
1353
- />
1354
- <FieldError>
1355
- {form.formState.errors.categorias?.message}
1356
- </FieldError>
1357
- </Field>
1358
-
1359
- {/* Flags */}
1360
- <div className="space-y-3">
1361
- <p className="text-sm font-medium">{t('form.flags.title')}</p>
1362
- {(
1363
- [
1364
- {
1365
- name: 'destaque' as const,
1366
- label: t('form.flags.featured.label'),
1367
- desc: t('form.flags.featured.description'),
1368
- },
1369
- {
1370
- name: 'certificado' as const,
1371
- label: t('form.flags.certificate.label'),
1372
- desc: t('form.flags.certificate.description'),
1373
- },
1374
- {
1375
- name: 'listado' as const,
1376
- label: t('form.flags.listed.label'),
1377
- desc: t('form.flags.listed.description'),
1378
- },
1379
- ] as const
1380
- ).map((flag) => (
1381
- <label
1382
- key={flag.name}
1383
- className="flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-muted"
1384
- >
1385
- <div>
1386
- <p className="text-sm font-medium">{flag.label}</p>
1387
- <p className="text-xs text-muted-foreground">{flag.desc}</p>
1388
- </div>
1389
- <Controller
1390
- name={flag.name}
1391
- control={form.control}
1392
- render={({ field }) => (
1393
- <Switch
1394
- checked={field.value}
1395
- onCheckedChange={field.onChange}
1396
- />
1397
- )}
1398
- />
1399
- </label>
1400
- ))}
1401
- </div>
1402
-
1403
- <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">
1404
- <Button type="submit" disabled={saving} className="gap-2">
1405
- {saving && <Loader2 className="size-4 animate-spin" />}
1406
- {editingCurso
1407
- ? t('form.actions.save')
1408
- : t('form.actions.create')}
1409
- </Button>
1410
- </SheetFooter>
1411
- </form>
1412
- </SheetContent>
1413
- </Sheet>
1374
+ <CourseFormSheet
1375
+ key={editingCurso ? `edit-${editingCurso.id}` : 'new'}
1376
+ open={sheetOpen}
1377
+ onOpenChange={(open) => {
1378
+ setSheetOpen(open);
1379
+ if (!open) setEditingCurso(null);
1380
+ }}
1381
+ editing={!!editingCurso}
1382
+ saving={saving}
1383
+ form={form}
1384
+ onSubmit={onSubmit}
1385
+ categories={categoryOptions}
1386
+ courseCode={editingCurso?.codigo}
1387
+ onCreateCategory={() => router.push('/category?new=1')}
1388
+ t={t}
1389
+ />
1414
1390
 
1415
1391
  {/* ── Delete dialog ────────────────────���────────────────────────────── */}
1416
1392
  <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>