@hed-hog/lms 0.0.306 → 0.0.309

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 (117) hide show
  1. package/dist/course/course-structure.controller.d.ts +60 -0
  2. package/dist/course/course-structure.controller.d.ts.map +1 -1
  3. package/dist/course/course-structure.controller.js +79 -0
  4. package/dist/course/course-structure.controller.js.map +1 -1
  5. package/dist/course/course-structure.service.d.ts +61 -1
  6. package/dist/course/course-structure.service.d.ts.map +1 -1
  7. package/dist/course/course-structure.service.js +326 -1
  8. package/dist/course/course-structure.service.js.map +1 -1
  9. package/dist/course/course.controller.d.ts +52 -4
  10. package/dist/course/course.controller.d.ts.map +1 -1
  11. package/dist/course/course.service.d.ts +52 -5
  12. package/dist/course/course.service.d.ts.map +1 -1
  13. package/dist/course/course.service.js +78 -57
  14. package/dist/course/course.service.js.map +1 -1
  15. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  16. package/dist/course/dto/create-course-structure-lesson.dto.js +5 -1
  17. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  18. package/dist/course/dto/create-course.dto.d.ts +1 -1
  19. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  20. package/dist/course/dto/create-course.dto.js +4 -1
  21. package/dist/course/dto/create-course.dto.js.map +1 -1
  22. package/dist/course/dto/move-lesson.dto.d.ts +10 -0
  23. package/dist/course/dto/move-lesson.dto.d.ts.map +1 -0
  24. package/dist/course/dto/move-lesson.dto.js +28 -0
  25. package/dist/course/dto/move-lesson.dto.js.map +1 -0
  26. package/dist/course/dto/paste-lessons.dto.d.ts +4 -0
  27. package/dist/course/dto/paste-lessons.dto.d.ts.map +1 -0
  28. package/dist/course/dto/paste-lessons.dto.js +24 -0
  29. package/dist/course/dto/paste-lessons.dto.js.map +1 -0
  30. package/dist/course/dto/reorder-lessons.dto.d.ts +5 -0
  31. package/dist/course/dto/reorder-lessons.dto.d.ts.map +1 -0
  32. package/dist/course/dto/reorder-lessons.dto.js +24 -0
  33. package/dist/course/dto/reorder-lessons.dto.js.map +1 -0
  34. package/dist/course/dto/reorder-sessions.dto.d.ts +5 -0
  35. package/dist/course/dto/reorder-sessions.dto.d.ts.map +1 -0
  36. package/dist/course/dto/reorder-sessions.dto.js +24 -0
  37. package/dist/course/dto/reorder-sessions.dto.js.map +1 -0
  38. package/dist/training/training.controller.js +1 -1
  39. package/dist/training/training.controller.js.map +1 -1
  40. package/hedhog/data/image_type.yaml +20 -0
  41. package/hedhog/data/menu.yaml +2 -2
  42. package/hedhog/data/route.yaml +60 -6
  43. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +146 -165
  44. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +70 -0
  45. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +372 -22
  46. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +437 -77
  47. package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
  48. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
  49. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
  50. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
  51. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
  52. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
  53. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
  54. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
  55. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
  58. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  59. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
  60. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
  61. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
  62. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  69. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
  70. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
  71. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
  72. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
  73. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
  74. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
  75. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
  76. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
  77. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
  78. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
  93. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
  94. package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
  95. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
  97. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
  98. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
  99. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
  100. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
  101. package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
  102. package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
  103. package/hedhog/frontend/messages/en.json +88 -10
  104. package/hedhog/frontend/messages/pt.json +88 -10
  105. package/hedhog/table/course.yaml +1 -1
  106. package/hedhog/table/image_type.yaml +14 -0
  107. package/package.json +7 -7
  108. package/src/course/course-structure.controller.ts +63 -0
  109. package/src/course/course-structure.service.ts +390 -3
  110. package/src/course/course.service.ts +59 -27
  111. package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
  112. package/src/course/dto/create-course.dto.ts +4 -1
  113. package/src/course/dto/move-lesson.dto.ts +17 -0
  114. package/src/course/dto/paste-lessons.dto.ts +9 -0
  115. package/src/course/dto/reorder-lessons.dto.ts +10 -0
  116. package/src/course/dto/reorder-sessions.dto.ts +10 -0
  117. package/src/training/training.controller.ts +1 -1
@@ -73,11 +73,18 @@ import {
73
73
  TableRow,
74
74
  } from '@/components/ui/table';
75
75
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
76
+ import {
77
+ Tooltip,
78
+ TooltipContent,
79
+ TooltipProvider,
80
+ TooltipTrigger,
81
+ } from '@/components/ui/tooltip';
76
82
  import { cn } from '@/lib/utils';
77
83
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
78
84
  import { zodResolver } from '@hookform/resolvers/zod';
79
85
  import {
80
86
  addMonths,
87
+ differenceInCalendarMonths,
81
88
  eachDayOfInterval,
82
89
  endOfMonth,
83
90
  endOfWeek,
@@ -121,6 +128,7 @@ import {
121
128
  useCallback,
122
129
  useEffect,
123
130
  useMemo,
131
+ useRef,
124
132
  useState,
125
133
  type ReactNode,
126
134
  } from 'react';
@@ -128,6 +136,14 @@ import { Controller, useForm } from 'react-hook-form';
128
136
  import { toast } from 'sonner';
129
137
  import { z } from 'zod';
130
138
  import { ClassFormSheet } from '../../_components/class-form-sheet';
139
+ import { CourseAvatar } from '../../_components/course-avatar';
140
+ import {
141
+ CourseCategoryOption,
142
+ CourseFormSheet,
143
+ CourseSheetFormValues,
144
+ DEFAULT_COURSE_FORM_VALUES,
145
+ getCourseSheetSchema,
146
+ } from '../../_components/course-form-sheet';
131
147
  import { CreateLmsPersonSheet } from '../../_components/create-lms-person-sheet';
132
148
  import { CreateLmsStudentPersonSheet } from '../../_components/create-lms-student-person-sheet';
133
149
 
@@ -345,6 +361,25 @@ type ClassDetail = {
345
361
  location?: string;
346
362
  };
347
363
 
364
+ type ApiCourseDetail = {
365
+ id: number;
366
+ code?: string;
367
+ slug?: string;
368
+ title: string;
369
+ description?: string;
370
+ level?: 'beginner' | 'intermediate' | 'advanced';
371
+ status?: 'draft' | 'published' | 'archived';
372
+ categories?: string[];
373
+ primaryColor?: string | null;
374
+ secondaryColor?: string | null;
375
+ logoFileId?: number | null;
376
+ };
377
+
378
+ type ApiCourseCategoryList = {
379
+ data: Array<{ id: number; slug: string; name: string; status?: string }>;
380
+ total?: number;
381
+ };
382
+
348
383
  type AttendanceRecord = {
349
384
  student_id: number;
350
385
  present: boolean;
@@ -356,6 +391,36 @@ type Locale = {
356
391
  name: string;
357
392
  };
358
393
 
394
+ function toCourseFormLevel(level?: string): CourseSheetFormValues['nivel'] {
395
+ if (level === 'intermediate') return 'intermediario';
396
+ if (level === 'advanced') return 'avancado';
397
+ return 'iniciante';
398
+ }
399
+ function toCourseFormStatus(status?: string): CourseSheetFormValues['status'] {
400
+ if (status === 'published') return 'ativo';
401
+ if (status === 'archived') return 'arquivado';
402
+ return 'rascunho';
403
+ }
404
+ function toApiCourseLevel(nivel: CourseSheetFormValues['nivel']) {
405
+ if (nivel === 'intermediario') return 'intermediate';
406
+ if (nivel === 'avancado') return 'advanced';
407
+ return 'beginner';
408
+ }
409
+ function toApiCourseStatus(status: CourseSheetFormValues['status']) {
410
+ if (status === 'ativo') return 'published';
411
+ if (status === 'arquivado') return 'archived';
412
+ return 'draft';
413
+ }
414
+ function getContrastColorCourse(hex: string) {
415
+ const cleaned = hex.replace('#', '');
416
+ if (cleaned.length !== 6) return '#FFFFFF';
417
+ const r = parseInt(cleaned.slice(0, 2), 16);
418
+ const g = parseInt(cleaned.slice(2, 4), 16);
419
+ const b = parseInt(cleaned.slice(4, 6), 16);
420
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
421
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
422
+ }
423
+
359
424
  const EMPTY_AULAS: Aula[] = [];
360
425
 
361
426
  const SESSION_RECURRENCE_FREQUENCIES = [
@@ -580,6 +645,7 @@ type StudentForm = z.infer<ReturnType<typeof getStudentSchema>>;
580
645
  export default function TurmaDetalhePage() {
581
646
  const t = useTranslations('lms.ClassesPage.DetailPage');
582
647
  const tClasses = useTranslations('lms.ClassesPage');
648
+ const tCourses = useTranslations('lms.CoursesPage');
583
649
  const locale = useLocale();
584
650
  const params = useParams();
585
651
  const router = useRouter();
@@ -656,6 +722,8 @@ export default function TurmaDetalhePage() {
656
722
 
657
723
  // ── Edit sheet state ──────────────────────────────────────────────────────
658
724
  const [editSheetOpen, setEditSheetOpen] = useState(false);
725
+ const [courseSheetOpen, setCourseSheetOpen] = useState(false);
726
+ const [savingCourse, setSavingCourse] = useState(false);
659
727
 
660
728
  // ── Tab state ─────────────────────────────────────────────────────────────
661
729
  const [activeTab, setActiveTab] = useState('alunos');
@@ -663,6 +731,36 @@ export default function TurmaDetalhePage() {
663
731
  'single' | 'quarter' | 'year' | 'list'
664
732
  >('single');
665
733
  const [calendarViewDate, setCalendarViewDate] = useState(() => new Date());
734
+ const calendarInitialized = useRef(false);
735
+
736
+ useEffect(() => {
737
+ if (calendarInitialized.current || !turma) return;
738
+
739
+ const rawStart = turma.startDate ?? turma.dataInicio;
740
+ const rawEnd = turma.endDate ?? turma.dataFim;
741
+
742
+ if (!rawStart) return;
743
+
744
+ const start = new Date(`${String(rawStart).slice(0, 10)}T00:00:00`);
745
+ const end = rawEnd
746
+ ? new Date(`${String(rawEnd).slice(0, 10)}T00:00:00`)
747
+ : start;
748
+
749
+ const monthDiff = differenceInCalendarMonths(end, start);
750
+
751
+ let mode: 'single' | 'quarter' | 'year' | 'list';
752
+ if (monthDiff === 0) {
753
+ mode = 'single';
754
+ } else if (monthDiff < 12) {
755
+ mode = 'quarter';
756
+ } else {
757
+ mode = 'list';
758
+ }
759
+
760
+ calendarInitialized.current = true;
761
+ setCalendarViewMode(mode);
762
+ setCalendarViewDate(startOfMonth(start));
763
+ }, [turma]);
666
764
 
667
765
  // ── Students state ────────────────────────────────────────────────────────
668
766
  const [alunoSearch, setAlunoSearch] = useState('');
@@ -772,11 +870,50 @@ export default function TurmaDetalhePage() {
772
870
  }
773
871
  }, [aulaSheetOpen, instructorOpen, refetchInstructorOptions]);
774
872
 
873
+ const { data: courseDetail, refetch: refetchCourseDetail } =
874
+ useQuery<ApiCourseDetail>({
875
+ queryKey: [
876
+ 'lms-course-for-class-sheet',
877
+ turma?.courseId ?? turma?.cursoId,
878
+ ],
879
+ queryFn: async () => {
880
+ const cId = turma?.courseId ?? turma?.cursoId;
881
+ const res = await request<ApiCourseDetail>({
882
+ url: `/lms/courses/${cId}`,
883
+ method: 'GET',
884
+ });
885
+ return res.data;
886
+ },
887
+ enabled: !!(turma?.courseId ?? turma?.cursoId),
888
+ });
889
+
890
+ const { data: courseCategoryData } = useQuery<ApiCourseCategoryList>({
891
+ queryKey: ['category-options-for-course-sheet'],
892
+ queryFn: async () => {
893
+ const res = await request<ApiCourseCategoryList>({
894
+ url: '/category',
895
+ method: 'GET',
896
+ params: { page: 1, pageSize: 500, status: 'all' },
897
+ });
898
+ return res.data;
899
+ },
900
+ enabled: courseSheetOpen,
901
+ });
902
+
775
903
  // ── Attendance state ──────────────────────────────────────────────────────
776
904
  const [presencaList, setPresencaList] = useState<PresencaItem[]>([]);
777
905
  const [savingPresenca, setSavingPresenca] = useState(false);
778
906
  const [aulasState, setAulasState] = useState<Aula[]>([]);
779
907
 
908
+ // attendanceMatrix: sessionId -> (studentId -> present)
909
+ const [attendanceMatrix, setAttendanceMatrix] = useState<
910
+ Record<number, Record<number, boolean>>
911
+ >({});
912
+ const [loadingAttendanceMatrix, setLoadingAttendanceMatrix] = useState(false);
913
+ const [savingAttendanceCells, setSavingAttendanceCells] = useState<
914
+ Set<string>
915
+ >(new Set());
916
+
780
917
  useEffect(() => {
781
918
  setAulasState(
782
919
  [...aulasQuery].sort(
@@ -787,6 +924,44 @@ export default function TurmaDetalhePage() {
787
924
  );
788
925
  }, [aulasQuery]);
789
926
 
927
+ useEffect(() => {
928
+ if (aulasState.length === 0 || alunos.length === 0) return;
929
+ let cancelled = false;
930
+ const load = async () => {
931
+ setLoadingAttendanceMatrix(true);
932
+ try {
933
+ const results = await Promise.all(
934
+ aulasState.map(async (aula) => {
935
+ try {
936
+ const res = await request<AttendanceRecord[]>({
937
+ url: `/lms/classes/${id}/sessions/${aula.id}/attendance`,
938
+ method: 'GET',
939
+ });
940
+ return { sessionId: aula.id, data: res.data };
941
+ } catch {
942
+ return { sessionId: aula.id, data: [] as AttendanceRecord[] };
943
+ }
944
+ })
945
+ );
946
+ if (cancelled) return;
947
+ const matrix: Record<number, Record<number, boolean>> = {};
948
+ for (const { sessionId, data } of results) {
949
+ matrix[sessionId] = {};
950
+ for (const record of data) {
951
+ matrix[sessionId][record.student_id] = record.present;
952
+ }
953
+ }
954
+ setAttendanceMatrix(matrix);
955
+ } finally {
956
+ if (!cancelled) setLoadingAttendanceMatrix(false);
957
+ }
958
+ };
959
+ void load();
960
+ return () => {
961
+ cancelled = true;
962
+ };
963
+ }, [aulasState, alunos, id, request]);
964
+
790
965
  // ── Form ──────────────────────────────────────────────────────────────────
791
966
  const aulaSchema = getAulaSchema(t);
792
967
  const studentSchema = getStudentSchema(t);
@@ -822,6 +997,11 @@ export default function TurmaDetalhePage() {
822
997
  },
823
998
  });
824
999
 
1000
+ const courseForm = useForm<CourseSheetFormValues>({
1001
+ resolver: zodResolver(getCourseSheetSchema(tCourses)),
1002
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
1003
+ });
1004
+
825
1005
  // ── Derived ───────────────────────────────────────────────────────────────
826
1006
  const filteredAlunos = useMemo(() => {
827
1007
  if (!alunoSearch.trim()) return alunos;
@@ -849,6 +1029,15 @@ export default function TurmaDetalhePage() {
849
1029
  [studentPickerResults]
850
1030
  );
851
1031
 
1032
+ const courseCategoryOptions = useMemo<CourseCategoryOption[]>(
1033
+ () =>
1034
+ (courseCategoryData?.data ?? [])
1035
+ .filter((c) => !!c.slug)
1036
+ .map((c) => ({ value: c.slug, label: c.name || c.slug }))
1037
+ .sort((a, b) => a.label.localeCompare(b.label)),
1038
+ [courseCategoryData]
1039
+ );
1040
+
852
1041
  useEffect(() => {
853
1042
  if (pessoasElegiveisParaMatricula.length === 0) return;
854
1043
 
@@ -892,6 +1081,54 @@ export default function TurmaDetalhePage() {
892
1081
  window.dispatchEvent(new CustomEvent('lms:dashboard-updated'));
893
1082
  };
894
1083
 
1084
+ function openCourseEditSheet() {
1085
+ if (!courseDetail) return;
1086
+ courseForm.reset({
1087
+ nomeInterno: courseDetail.slug ?? courseDetail.code ?? '',
1088
+ tituloComercial: courseDetail.title ?? '',
1089
+ descricao: courseDetail.description ?? '',
1090
+ nivel: toCourseFormLevel(courseDetail.level),
1091
+ status: toCourseFormStatus(courseDetail.status),
1092
+ categorias: courseDetail.categories ?? [],
1093
+ primaryColor: courseDetail.primaryColor ?? '#1D4ED8',
1094
+ secondaryColor: courseDetail.secondaryColor ?? '#111827',
1095
+ logoFileId: courseDetail.logoFileId ?? null,
1096
+ });
1097
+ setCourseSheetOpen(true);
1098
+ }
1099
+
1100
+ async function onSubmitCourse(data: CourseSheetFormValues) {
1101
+ const cId = turma?.courseId ?? turma?.cursoId;
1102
+ if (!cId) return;
1103
+ setSavingCourse(true);
1104
+ try {
1105
+ await request({
1106
+ url: `/lms/courses/${cId}`,
1107
+ method: 'PATCH',
1108
+ data: {
1109
+ slug: data.nomeInterno.trim(),
1110
+ title: data.tituloComercial,
1111
+ description: data.descricao,
1112
+ level: toApiCourseLevel(data.nivel),
1113
+ status: toApiCourseStatus(data.status),
1114
+ categorySlugs: data.categorias,
1115
+ primaryColor: data.primaryColor,
1116
+ primaryContrastColor: getContrastColorCourse(data.primaryColor),
1117
+ secondaryColor: data.secondaryColor,
1118
+ secondaryContrastColor: getContrastColorCourse(data.secondaryColor),
1119
+ logoFileId: data.logoFileId ?? null,
1120
+ },
1121
+ });
1122
+ await refetchCourseDetail();
1123
+ setCourseSheetOpen(false);
1124
+ toast.success('Curso atualizado com sucesso.');
1125
+ } catch {
1126
+ toast.error('Não foi possível salvar o curso.');
1127
+ } finally {
1128
+ setSavingCourse(false);
1129
+ }
1130
+ }
1131
+
895
1132
  // ── Handlers ─────────────────────────────────────────────────────────────
896
1133
  const toggleSelectAluno = (studentId: number, e?: React.MouseEvent) => {
897
1134
  if (e?.shiftKey && selectedAlunos.length > 0) {
@@ -1263,6 +1500,49 @@ export default function TurmaDetalhePage() {
1263
1500
  openAulaSheet(aula, { initialTab: 'chamada' });
1264
1501
  };
1265
1502
 
1503
+ const toggleAttendanceCell = async (sessionId: number, studentId: number) => {
1504
+ const current = attendanceMatrix[sessionId]?.[studentId] ?? false;
1505
+ const next = !current;
1506
+ const cellKey = `${sessionId}-${studentId}`;
1507
+
1508
+ // Build full session attendance payload before the optimistic update
1509
+ const sessionAttendance = alunos.map((a) => ({
1510
+ studentId: a.id,
1511
+ present:
1512
+ a.id === studentId
1513
+ ? next
1514
+ : (attendanceMatrix[sessionId]?.[a.id] ?? false),
1515
+ }));
1516
+
1517
+ // Optimistic update
1518
+ setAttendanceMatrix((prev) => ({
1519
+ ...prev,
1520
+ [sessionId]: { ...(prev[sessionId] ?? {}), [studentId]: next },
1521
+ }));
1522
+ setSavingAttendanceCells((prev) => new Set(prev).add(cellKey));
1523
+
1524
+ try {
1525
+ await request({
1526
+ url: `/lms/classes/${id}/sessions/${sessionId}/attendance`,
1527
+ method: 'POST',
1528
+ data: { attendance: sessionAttendance },
1529
+ });
1530
+ } catch {
1531
+ // Rollback on error
1532
+ setAttendanceMatrix((prev) => ({
1533
+ ...prev,
1534
+ [sessionId]: { ...(prev[sessionId] ?? {}), [studentId]: current },
1535
+ }));
1536
+ toast.error(t('toasts.error'));
1537
+ } finally {
1538
+ setSavingAttendanceCells((prev) => {
1539
+ const updated = new Set(prev);
1540
+ updated.delete(cellKey);
1541
+ return updated;
1542
+ });
1543
+ }
1544
+ };
1545
+
1266
1546
  const togglePresenca = (alunoId: number) => {
1267
1547
  setPresencaList((prev) =>
1268
1548
  prev.map((p) =>
@@ -2346,34 +2626,40 @@ export default function TurmaDetalhePage() {
2346
2626
 
2347
2627
  {/* ── Tab Presenca ────────────────────────────────────────────── */}
2348
2628
  <TabsContent value="presenca" className="mt-0">
2349
- <p className="mb-4 text-sm text-muted-foreground">
2350
- {t('attendance.helper')}
2351
- </p>
2352
- {loading ? (
2353
- <div className="grid gap-3 sm:grid-cols-2">
2354
- {Array.from({ length: 4 }).map((_, i) => (
2355
- <Card
2356
- key={i}
2357
- className="overflow-hidden border-border/70"
2358
- >
2359
- <CardContent className="p-4">
2360
- <Skeleton className="h-20" />
2361
- </CardContent>
2362
- </Card>
2629
+ {loading || loadingAttendanceMatrix ? (
2630
+ <div className="space-y-2">
2631
+ {Array.from({ length: 5 }).map((_, i) => (
2632
+ <Skeleton key={i} className="h-10 w-full" />
2363
2633
  ))}
2364
2634
  </div>
2635
+ ) : alunos.length === 0 ? (
2636
+ <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
2637
+ <div className="flex flex-col items-center gap-3">
2638
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/60 text-muted-foreground">
2639
+ <Users className="size-5" />
2640
+ </div>
2641
+ <div>
2642
+ <p className="font-semibold text-sm">
2643
+ {t('students.empty.notEnrolled')}
2644
+ </p>
2645
+ <p className="mt-0.5 text-xs text-muted-foreground">
2646
+ {t('students.empty.notEnrolledDescription')}
2647
+ </p>
2648
+ </div>
2649
+ </div>
2650
+ </div>
2365
2651
  ) : aulasState.length === 0 ? (
2366
2652
  <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
2367
2653
  <div className="flex flex-col items-center gap-3">
2368
2654
  <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/60 text-muted-foreground">
2369
- <CheckCircle2 className="size-5" />
2655
+ <CalendarIcon className="size-5" />
2370
2656
  </div>
2371
2657
  <div>
2372
2658
  <p className="font-semibold text-sm">
2373
- {t('attendance.empty.title')}
2659
+ {t('calendar.empty.title')}
2374
2660
  </p>
2375
2661
  <p className="mt-0.5 text-xs text-muted-foreground">
2376
- {t('attendance.empty.description')}
2662
+ {t('calendar.empty.description')}
2377
2663
  </p>
2378
2664
  </div>
2379
2665
  <Button
@@ -2391,67 +2677,72 @@ export default function TurmaDetalhePage() {
2391
2677
  </div>
2392
2678
  </div>
2393
2679
  ) : (
2394
- <div className="grid gap-3 sm:grid-cols-2">
2395
- {aulasState
2396
- .slice(-12)
2397
- .reverse()
2398
- .map((aula) => {
2399
- const sessionColor =
2400
- aula.cor || aula.color || '#3b82f6';
2401
- const isPast = getSessionEndDate(aula) < now;
2402
- return (
2403
- <Card
2404
- key={aula.id}
2405
- className="cursor-pointer overflow-hidden border-border/60 transition-all hover:-translate-y-0.5 hover:shadow-md"
2406
- onClick={() => openPresenca(aula)}
2407
- >
2408
- <div className="flex">
2409
- <div
2410
- className="w-1 shrink-0"
2411
- style={{ backgroundColor: sessionColor }}
2412
- />
2413
- <CardContent className="flex-1 p-3.5">
2414
- <div className="flex items-start justify-between gap-2">
2415
- <div className="min-w-0">
2416
- <h4 className="truncate text-sm font-semibold">
2417
- {aula.titulo}
2418
- </h4>
2419
- <p className="text-xs capitalize text-muted-foreground">
2420
- {format(
2421
- parseSessionDate(aula.data),
2422
- 'EEE, dd/MM',
2423
- { locale: dateLocale }
2424
- )}
2425
- {' · '}
2426
- {aula.horaInicio} – {aula.horaFim}
2427
- </p>
2428
- </div>
2429
- <Badge
2430
- variant="outline"
2431
- className={cn(
2432
- 'shrink-0 text-[10px]',
2433
- isPast
2434
- ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
2435
- : ''
2436
- )}
2437
- >
2438
- {isPast ? (
2439
- <CheckCircle2 className="mr-1 size-3" />
2440
- ) : aula.tipo === 'online' ? (
2441
- <Video className="mr-1 size-3" />
2442
- ) : (
2443
- <MapPin className="mr-1 size-3" />
2444
- )}
2445
- {isPast
2446
- ? t('attendance.register')
2447
- : tClasses(`type.${aula.tipo}`)}
2448
- </Badge>
2680
+ <div className="overflow-x-auto rounded-xl border border-border/60">
2681
+ <Table>
2682
+ <TableHeader>
2683
+ <TableRow className="bg-muted/40 hover:bg-muted/40">
2684
+ <TableHead className="sticky left-0 z-10 min-w-[180px] bg-muted/40 font-semibold">
2685
+ {t('tabs.students')}
2686
+ </TableHead>
2687
+ {aulasState.map((aula) => {
2688
+ const d = parseSessionDate(aula.data);
2689
+ return (
2690
+ <TableHead
2691
+ key={aula.id}
2692
+ className="min-w-[68px] text-center text-xs"
2693
+ >
2694
+ <div className="flex flex-col items-center gap-0.5">
2695
+ <span className="capitalize text-muted-foreground">
2696
+ {format(d, 'EEE', {
2697
+ locale: dateLocale,
2698
+ })}
2699
+ </span>
2700
+ <span>{format(d, 'dd/MM')}</span>
2449
2701
  </div>
2450
- </CardContent>
2451
- </div>
2452
- </Card>
2453
- );
2454
- })}
2702
+ </TableHead>
2703
+ );
2704
+ })}
2705
+ </TableRow>
2706
+ </TableHeader>
2707
+ <TableBody>
2708
+ {alunos.map((aluno) => (
2709
+ <TableRow
2710
+ key={aluno.id}
2711
+ className="hover:bg-muted/30"
2712
+ >
2713
+ <TableCell className="sticky left-0 z-10 border-r border-border/40 bg-background text-sm font-medium">
2714
+ {aluno.nome}
2715
+ </TableCell>
2716
+ {aulasState.map((aula) => {
2717
+ const cellKey = `${aula.id}-${aluno.id}`;
2718
+ const present =
2719
+ attendanceMatrix[aula.id]?.[aluno.id] ??
2720
+ false;
2721
+ const saving =
2722
+ savingAttendanceCells.has(cellKey);
2723
+ return (
2724
+ <TableCell
2725
+ key={aula.id}
2726
+ className="text-center"
2727
+ >
2728
+ <Checkbox
2729
+ checked={present}
2730
+ disabled={saving}
2731
+ onCheckedChange={() =>
2732
+ void toggleAttendanceCell(
2733
+ aula.id,
2734
+ aluno.id
2735
+ )
2736
+ }
2737
+ aria-label={`${aluno.nome} – ${format(parseSessionDate(aula.data), 'dd/MM/yyyy')}`}
2738
+ />
2739
+ </TableCell>
2740
+ );
2741
+ })}
2742
+ </TableRow>
2743
+ ))}
2744
+ </TableBody>
2745
+ </Table>
2455
2746
  </div>
2456
2747
  )}
2457
2748
  </TabsContent>
@@ -2589,6 +2880,62 @@ export default function TurmaDetalhePage() {
2589
2880
  title={t('sidebar.classInfo') ?? 'Informações da Turma'}
2590
2881
  >
2591
2882
  <div className="space-y-2.5">
2883
+ {/* Course */}
2884
+ <div className="flex items-center justify-between gap-2">
2885
+ <div className="flex min-w-0 items-center gap-2">
2886
+ <CourseAvatar
2887
+ fileId={courseDetail?.logoFileId}
2888
+ title={classTitle}
2889
+ className="size-8 shrink-0 rounded-lg"
2890
+ iconSize="size-4"
2891
+ />
2892
+ <div className="min-w-0">
2893
+ <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
2894
+ Curso
2895
+ </p>
2896
+ <p className="truncate text-xs font-medium text-foreground">
2897
+ {classTitle || '—'}
2898
+ </p>
2899
+ </div>
2900
+ </div>
2901
+ <TooltipProvider delayDuration={300}>
2902
+ <div className="flex shrink-0 items-center gap-0.5">
2903
+ <Tooltip>
2904
+ <TooltipTrigger asChild>
2905
+ <Button
2906
+ variant="ghost"
2907
+ size="icon"
2908
+ className="size-6"
2909
+ onClick={openCourseEditSheet}
2910
+ disabled={!courseDetail}
2911
+ >
2912
+ <Pencil className="size-3" />
2913
+ </Button>
2914
+ </TooltipTrigger>
2915
+ <TooltipContent side="top" className="text-xs">
2916
+ Editar curso
2917
+ </TooltipContent>
2918
+ </Tooltip>
2919
+ <Tooltip>
2920
+ <TooltipTrigger asChild>
2921
+ <Button
2922
+ variant="ghost"
2923
+ size="icon"
2924
+ className="size-6"
2925
+ onClick={handleViewCourse}
2926
+ >
2927
+ <Eye className="size-3" />
2928
+ </Button>
2929
+ </TooltipTrigger>
2930
+ <TooltipContent side="top" className="text-xs">
2931
+ Ver detalhes do curso
2932
+ </TooltipContent>
2933
+ </Tooltip>
2934
+ </div>
2935
+ </TooltipProvider>
2936
+ </div>
2937
+ <div className="my-0.5 border-t border-border/40" />
2938
+
2592
2939
  {startDate && endDate && (
2593
2940
  <div className="flex items-start gap-2 text-xs">
2594
2941
  <CalendarIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
@@ -3531,6 +3878,19 @@ export default function TurmaDetalhePage() {
3531
3878
  defaultQualificationSlugs={['class-sessions']}
3532
3879
  />
3533
3880
 
3881
+ <CourseFormSheet
3882
+ key={`course-edit-${courseDetail?.id ?? 'none'}`}
3883
+ open={courseSheetOpen}
3884
+ onOpenChange={setCourseSheetOpen}
3885
+ editing={true}
3886
+ saving={savingCourse}
3887
+ form={courseForm}
3888
+ onSubmit={onSubmitCourse}
3889
+ categories={courseCategoryOptions}
3890
+ initialLogoFileId={courseDetail?.logoFileId ?? null}
3891
+ t={tCourses}
3892
+ />
3893
+
3534
3894
  <ClassFormSheet
3535
3895
  open={editSheetOpen}
3536
3896
  onOpenChange={setEditSheetOpen}