@hed-hog/lms 0.0.319 → 0.0.321

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 (128) hide show
  1. package/dist/class-group/class-group.controller.d.ts +64 -1
  2. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  3. package/dist/class-group/class-group.controller.js +35 -0
  4. package/dist/class-group/class-group.controller.js.map +1 -1
  5. package/dist/class-group/class-group.service.d.ts +66 -1
  6. package/dist/class-group/class-group.service.d.ts.map +1 -1
  7. package/dist/class-group/class-group.service.js +164 -13
  8. package/dist/class-group/class-group.service.js.map +1 -1
  9. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -1
  10. package/dist/class-group/dto/create-class-group.dto.js +2 -1
  11. package/dist/class-group/dto/create-class-group.dto.js.map +1 -1
  12. package/dist/class-group/dto/material.dto.d.ts +18 -0
  13. package/dist/class-group/dto/material.dto.d.ts.map +1 -0
  14. package/dist/class-group/dto/material.dto.js +86 -0
  15. package/dist/class-group/dto/material.dto.js.map +1 -0
  16. package/dist/course/course.service.d.ts +2 -0
  17. package/dist/course/course.service.d.ts.map +1 -1
  18. package/dist/course/course.service.js +27 -2
  19. package/dist/course/course.service.js.map +1 -1
  20. package/dist/course/dto/create-course.dto.d.ts +2 -2
  21. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  22. package/dist/course/dto/create-course.dto.js.map +1 -1
  23. package/dist/enterprise/enterprise.controller.d.ts +7 -1
  24. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  25. package/dist/enterprise/enterprise.controller.js +72 -2
  26. package/dist/enterprise/enterprise.controller.js.map +1 -1
  27. package/dist/enterprise/enterprise.module.d.ts.map +1 -1
  28. package/dist/enterprise/enterprise.module.js +2 -1
  29. package/dist/enterprise/enterprise.module.js.map +1 -1
  30. package/dist/enterprise/enterprise.service.d.ts +3 -0
  31. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  32. package/dist/enterprise/enterprise.service.js +84 -1
  33. package/dist/enterprise/enterprise.service.js.map +1 -1
  34. package/dist/enterprise/training/enterprise-training.module.d.ts +3 -0
  35. package/dist/enterprise/training/enterprise-training.module.d.ts.map +1 -0
  36. package/dist/enterprise/training/enterprise-training.module.js +40 -0
  37. package/dist/enterprise/training/enterprise-training.module.js.map +1 -0
  38. package/dist/enterprise/training/training-admin.controller.d.ts +525 -0
  39. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -0
  40. package/dist/enterprise/training/training-admin.controller.js +385 -0
  41. package/dist/enterprise/training/training-admin.controller.js.map +1 -0
  42. package/dist/enterprise/training/training-admin.service.d.ts +582 -0
  43. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -0
  44. package/dist/enterprise/training/training-admin.service.js +2283 -0
  45. package/dist/enterprise/training/training-admin.service.js.map +1 -0
  46. package/dist/enterprise/training/training-instructor.controller.d.ts +260 -0
  47. package/dist/enterprise/training/training-instructor.controller.d.ts.map +1 -0
  48. package/dist/enterprise/training/training-instructor.controller.js +199 -0
  49. package/dist/enterprise/training/training-instructor.controller.js.map +1 -0
  50. package/dist/enterprise/training/training-instructor.service.d.ts +280 -0
  51. package/dist/enterprise/training/training-instructor.service.d.ts.map +1 -0
  52. package/dist/enterprise/training/training-instructor.service.js +1218 -0
  53. package/dist/enterprise/training/training-instructor.service.js.map +1 -0
  54. package/dist/enterprise/training/training-student.controller.d.ts +168 -0
  55. package/dist/enterprise/training/training-student.controller.d.ts.map +1 -0
  56. package/dist/enterprise/training/training-student.controller.js +104 -0
  57. package/dist/enterprise/training/training-student.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-student.service.d.ts +185 -0
  59. package/dist/enterprise/training/training-student.service.d.ts.map +1 -0
  60. package/dist/enterprise/training/training-student.service.js +674 -0
  61. package/dist/enterprise/training/training-student.service.js.map +1 -0
  62. package/dist/enterprise/training/training-viewer.controller.d.ts +298 -0
  63. package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -0
  64. package/dist/enterprise/training/training-viewer.controller.js +178 -0
  65. package/dist/enterprise/training/training-viewer.controller.js.map +1 -0
  66. package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts +18 -0
  67. package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts.map +1 -0
  68. package/dist/evaluation/dto/create-evaluation-topic.dto.js +59 -0
  69. package/dist/evaluation/dto/create-evaluation-topic.dto.js.map +1 -0
  70. package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts +6 -0
  71. package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts.map +1 -0
  72. package/dist/evaluation/dto/update-evaluation-topic.dto.js +9 -0
  73. package/dist/evaluation/dto/update-evaluation-topic.dto.js.map +1 -0
  74. package/dist/evaluation/evaluation.controller.d.ts +66 -0
  75. package/dist/evaluation/evaluation.controller.d.ts.map +1 -1
  76. package/dist/evaluation/evaluation.controller.js +73 -0
  77. package/dist/evaluation/evaluation.controller.js.map +1 -1
  78. package/dist/evaluation/evaluation.service.d.ts +71 -0
  79. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  80. package/dist/evaluation/evaluation.service.js +121 -0
  81. package/dist/evaluation/evaluation.service.js.map +1 -1
  82. package/dist/instructor/instructor.service.js +6 -6
  83. package/dist/instructor/instructor.service.js.map +1 -1
  84. package/dist/lms.module.d.ts.map +1 -1
  85. package/dist/lms.module.js +3 -0
  86. package/dist/lms.module.js.map +1 -1
  87. package/hedhog/data/menu.yaml +19 -2
  88. package/hedhog/data/route.yaml +730 -0
  89. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +74 -8
  90. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +10 -30
  91. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +3 -3
  92. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1 -1
  93. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +2141 -308
  94. package/hedhog/frontend/app/classes/page.tsx.ejs +8 -7
  95. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +14 -1
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +6 -2
  97. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +201 -0
  98. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-types.ts.ejs +49 -0
  99. package/hedhog/frontend/app/evaluations/page.tsx.ejs +483 -1112
  100. package/hedhog/frontend/app/instructors/page.tsx.ejs +22 -20
  101. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +1278 -0
  102. package/hedhog/frontend/messages/en.json +98 -7
  103. package/hedhog/frontend/messages/pt.json +98 -7
  104. package/hedhog/table/course_class_group_material.yaml +45 -0
  105. package/package.json +8 -8
  106. package/src/class-group/class-group.controller.ts +30 -0
  107. package/src/class-group/class-group.service.ts +176 -5
  108. package/src/class-group/dto/create-class-group.dto.ts +2 -2
  109. package/src/class-group/dto/material.dto.ts +69 -0
  110. package/src/course/course.service.ts +41 -8
  111. package/src/course/dto/create-course.dto.ts +2 -2
  112. package/src/enterprise/enterprise.controller.ts +62 -1
  113. package/src/enterprise/enterprise.module.ts +2 -1
  114. package/src/enterprise/enterprise.service.ts +84 -1
  115. package/src/enterprise/training/enterprise-training.module.ts +27 -0
  116. package/src/enterprise/training/training-admin.controller.ts +278 -0
  117. package/src/enterprise/training/training-admin.service.ts +2523 -0
  118. package/src/enterprise/training/training-instructor.controller.ts +141 -0
  119. package/src/enterprise/training/training-instructor.service.ts +1303 -0
  120. package/src/enterprise/training/training-student.controller.ts +65 -0
  121. package/src/enterprise/training/training-student.service.ts +762 -0
  122. package/src/enterprise/training/training-viewer.controller.ts +115 -0
  123. package/src/evaluation/dto/create-evaluation-topic.dto.ts +48 -0
  124. package/src/evaluation/dto/update-evaluation-topic.dto.ts +6 -0
  125. package/src/evaluation/evaluation.controller.ts +63 -1
  126. package/src/evaluation/evaluation.service.ts +150 -1
  127. package/src/instructor/instructor.service.ts +4 -4
  128. package/src/lms.module.ts +3 -0
@@ -1,9 +1,15 @@
1
1
  'use client';
2
2
 
3
+ import { PersonFormSheet } from '@/app/(app)/(libraries)/contact/person/_components/person-form-sheet';
4
+ import type {
5
+ Person as ContactPerson,
6
+ ContactTypeOption,
7
+ DocumentTypeOption,
8
+ } from '@/app/(app)/(libraries)/contact/person/_components/person-types';
3
9
  import { LmsClassCalendar } from '@/app/(app)/(libraries)/lms/_components/lms-class-calendar';
4
10
  import { CopyButton } from '@/components/copy-button';
5
11
  import { Page, PageHeader } from '@/components/entity-list';
6
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
12
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
7
13
  import { Badge } from '@/components/ui/badge';
8
14
  import { Button } from '@/components/ui/button';
9
15
  import { Card, CardContent } from '@/components/ui/card';
@@ -32,14 +38,6 @@ import {
32
38
  DropdownMenuTrigger,
33
39
  } from '@/components/ui/dropdown-menu';
34
40
  import { Field, FieldError, FieldLabel } from '@/components/ui/field';
35
- import {
36
- Form,
37
- FormControl,
38
- FormField,
39
- FormItem,
40
- FormLabel,
41
- FormMessage,
42
- } from '@/components/ui/form';
43
41
  import { Input } from '@/components/ui/input';
44
42
  import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
45
43
  import {
@@ -108,19 +106,31 @@ import {
108
106
  ChevronRight,
109
107
  ChevronsUpDown,
110
108
  Clock,
109
+ ExternalLink,
111
110
  Eye,
111
+ File,
112
+ FileAudio,
113
+ FileCode,
114
+ FileImage,
115
+ FileText,
116
+ FileVideo,
117
+ Link,
112
118
  Loader2,
113
119
  MapPin,
114
120
  Monitor,
115
121
  MoreHorizontal,
122
+ Paperclip,
116
123
  Pencil,
117
124
  Plus,
118
125
  Save,
119
126
  Search,
127
+ Trash2,
128
+ Upload,
120
129
  UserMinus,
121
130
  UserPlus,
122
131
  Users,
123
132
  Video,
133
+ X,
124
134
  } from 'lucide-react';
125
135
  import { useLocale, useTranslations } from 'next-intl';
126
136
  import { useParams, useRouter } from 'next/navigation';
@@ -155,6 +165,7 @@ interface Aluno {
155
165
  nome: string;
156
166
  email: string;
157
167
  telefone: string;
168
+ avatarId?: number | null;
158
169
  matriculadoEm: string;
159
170
  progresso: number;
160
171
  status: string;
@@ -163,6 +174,7 @@ interface Aluno {
163
174
  interface Aula {
164
175
  id: number;
165
176
  titulo: string;
177
+ descricao?: string;
166
178
  data: string; // ISO date string from API
167
179
  horaInicio: string;
168
180
  horaFim: string;
@@ -172,6 +184,7 @@ interface Aula {
172
184
  status: string;
173
185
  instructorId?: number | null;
174
186
  instructorName?: string;
187
+ instructorAvatarId?: number | null;
175
188
  recurrence?: {
176
189
  frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
177
190
  interval: number;
@@ -184,6 +197,35 @@ interface Aula {
184
197
  isRecurring?: boolean;
185
198
  cor?: string | null;
186
199
  color?: string | null;
200
+ materialCount?: number;
201
+ }
202
+
203
+ interface Material {
204
+ id: number;
205
+ title: string;
206
+ description?: string | null;
207
+ material_type: 'file' | 'link';
208
+ file_id?: number | null;
209
+ url?: string | null;
210
+ sort_order: number;
211
+ file?: {
212
+ id: number;
213
+ filename: string;
214
+ path: string;
215
+ size: number;
216
+ } | null;
217
+ course_class_session?: {
218
+ id: number;
219
+ title: string;
220
+ } | null;
221
+ }
222
+
223
+ interface UploadItem {
224
+ id: string;
225
+ file: File;
226
+ progress: number;
227
+ status: 'uploading' | 'done' | 'error';
228
+ title: string;
187
229
  }
188
230
 
189
231
  type SessionRecurrenceFrequency =
@@ -199,6 +241,7 @@ interface InstructorOption {
199
241
  id: number;
200
242
  name: string;
201
243
  personId?: number;
244
+ avatarId?: number | null;
202
245
  qualificationSlugs?: string[];
203
246
  }
204
247
 
@@ -212,6 +255,8 @@ type InstructorApiRow = {
212
255
  label?: string;
213
256
  personId?: number | string;
214
257
  person_id?: number | string;
258
+ avatarId?: number | null;
259
+ avatar_id?: number | null;
215
260
  qualificationSlugs?: string[];
216
261
  };
217
262
 
@@ -219,6 +264,7 @@ interface Person {
219
264
  id: number;
220
265
  nome: string;
221
266
  email: string;
267
+ avatarId?: number | null;
222
268
  isInstructor?: boolean;
223
269
  canTeachCourses?: boolean;
224
270
  isStudentByEnrollment?: boolean;
@@ -242,6 +288,7 @@ interface StudentProfile {
242
288
  nome: string;
243
289
  email: string;
244
290
  telefone: string;
291
+ avatarId?: number | null;
245
292
  matriculadoEm: string;
246
293
  progresso: number;
247
294
  status: string;
@@ -271,6 +318,12 @@ function getPersonInitials(name: string) {
271
318
  .join('');
272
319
  }
273
320
 
321
+ function getPersonAvatarUrl(avatarId?: number | null) {
322
+ return typeof avatarId === 'number' && avatarId > 0
323
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
324
+ : undefined;
325
+ }
326
+
274
327
  function getSessionStartDate(aula: Aula) {
275
328
  const date = parseSessionDate(aula.data);
276
329
  const [hour = 0, minute = 0] = aula.horaInicio.split(':').map(Number);
@@ -285,6 +338,100 @@ function getSessionEndDate(aula: Aula) {
285
338
  return setMinutes(setHours(date, hour), minute);
286
339
  }
287
340
 
341
+ function detectMeetingPlatform(
342
+ url?: string | null
343
+ ): 'teams' | 'zoom' | 'meet' | 'other' {
344
+ if (!url) return 'other';
345
+ try {
346
+ const hostname = new URL(url).hostname.toLowerCase();
347
+ if (
348
+ hostname.includes('teams.microsoft.com') ||
349
+ hostname.includes('teams.live.com')
350
+ )
351
+ return 'teams';
352
+ if (hostname.includes('zoom.us')) return 'zoom';
353
+ if (hostname.includes('meet.google.com')) return 'meet';
354
+ } catch {
355
+ // Invalid URL — fall through
356
+ }
357
+ return 'other';
358
+ }
359
+
360
+ function MeetingPlatformIcon({
361
+ url,
362
+ className,
363
+ }: {
364
+ url?: string | null;
365
+ className?: string;
366
+ }) {
367
+ const platform = detectMeetingPlatform(url);
368
+ const cls = cn('inline-block shrink-0 rounded', className);
369
+
370
+ if (platform === 'teams') {
371
+ return (
372
+ <svg
373
+ className={cls}
374
+ viewBox="0 0 20 20"
375
+ xmlns="http://www.w3.org/2000/svg"
376
+ aria-label="Microsoft Teams"
377
+ >
378
+ <rect width="20" height="20" rx="3" fill="#5059C9" />
379
+ <circle cx="13" cy="5.5" r="2" fill="white" fillOpacity="0.9" />
380
+ <rect
381
+ x="7"
382
+ y="8"
383
+ width="8"
384
+ height="7"
385
+ rx="1.5"
386
+ fill="white"
387
+ fillOpacity="0.9"
388
+ />
389
+ <circle cx="7" cy="6.5" r="1.5" fill="white" />
390
+ <path
391
+ d="M4.5 8.5H7v5.5A3.5 3.5 0 0 1 5.5 13V10H4.5a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"
392
+ fill="white"
393
+ />
394
+ </svg>
395
+ );
396
+ }
397
+
398
+ if (platform === 'zoom') {
399
+ return (
400
+ <svg
401
+ className={cls}
402
+ viewBox="0 0 20 20"
403
+ xmlns="http://www.w3.org/2000/svg"
404
+ aria-label="Zoom"
405
+ >
406
+ <rect width="20" height="20" rx="3" fill="#2D8CFF" />
407
+ <path
408
+ d="M3 7.5A1 1 0 0 1 4 6.5h7a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5zm9 1.5 5-3v7l-5-3V9z"
409
+ fill="white"
410
+ />
411
+ </svg>
412
+ );
413
+ }
414
+
415
+ if (platform === 'meet') {
416
+ return (
417
+ <svg
418
+ className={cls}
419
+ viewBox="0 0 20 20"
420
+ xmlns="http://www.w3.org/2000/svg"
421
+ aria-label="Google Meet"
422
+ >
423
+ <rect width="20" height="20" rx="3" fill="#00897B" />
424
+ <path
425
+ d="M3 7.5A1 1 0 0 1 4 6.5h7a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5zm9 1.5 5-3v7l-5-3V9z"
426
+ fill="white"
427
+ />
428
+ </svg>
429
+ );
430
+ }
431
+
432
+ return <ExternalLink className={cn('text-muted-foreground', className)} />;
433
+ }
434
+
288
435
  function DetailMetaItem({
289
436
  icon,
290
437
  label,
@@ -345,6 +492,7 @@ type ClassDetail = {
345
492
  cursoId?: number;
346
493
  courseTitle?: string;
347
494
  curso?: string;
495
+ logoFileId?: number | null;
348
496
  status?: string;
349
497
  capacity?: number;
350
498
  startDate?: string;
@@ -356,6 +504,7 @@ type ClassDetail = {
356
504
  horario?: string;
357
505
  deliveryMode?: 'presential' | 'online' | 'hybrid';
358
506
  tipo?: 'presencial' | 'online' | 'hibrida';
507
+ instructorAvatarId?: number | null;
359
508
  virtualRoomUrl?: string;
360
509
  local?: string;
361
510
  location?: string;
@@ -551,12 +700,147 @@ function normalizeInstructorOption(
551
700
  id,
552
701
  name,
553
702
  personId: Number(item?.personId ?? item?.person_id ?? 0) || undefined,
703
+ avatarId: item?.avatarId ?? item?.avatar_id ?? null,
554
704
  qualificationSlugs: Array.isArray(item?.qualificationSlugs)
555
705
  ? item.qualificationSlugs
556
706
  : undefined,
557
707
  };
558
708
  }
559
709
 
710
+ // ── Material helpers ─────────────────────────────────────────────────────────
711
+
712
+ type FileIconConfig = {
713
+ Icon: React.ElementType;
714
+ bg: string;
715
+ color: string;
716
+ };
717
+
718
+ function getFileIconConfig(filename: string): FileIconConfig {
719
+ const ext = (filename.split('.').pop() ?? '').toLowerCase();
720
+ if (ext === 'pdf')
721
+ return {
722
+ Icon: FileText,
723
+ bg: 'bg-red-100 dark:bg-red-950',
724
+ color: 'text-red-600 dark:text-red-400',
725
+ };
726
+ if (['doc', 'docx', 'odt', 'rtf'].includes(ext))
727
+ return {
728
+ Icon: FileText,
729
+ bg: 'bg-blue-100 dark:bg-blue-950',
730
+ color: 'text-blue-600 dark:text-blue-400',
731
+ };
732
+ if (['xls', 'xlsx', 'ods', 'csv'].includes(ext))
733
+ return {
734
+ Icon: FileText,
735
+ bg: 'bg-green-100 dark:bg-green-950',
736
+ color: 'text-green-600 dark:text-green-400',
737
+ };
738
+ if (['ppt', 'pptx', 'odp'].includes(ext))
739
+ return {
740
+ Icon: FileText,
741
+ bg: 'bg-orange-100 dark:bg-orange-950',
742
+ color: 'text-orange-600 dark:text-orange-400',
743
+ };
744
+ if (
745
+ ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp', 'avif'].includes(
746
+ ext
747
+ )
748
+ )
749
+ return {
750
+ Icon: FileImage,
751
+ bg: 'bg-purple-100 dark:bg-purple-950',
752
+ color: 'text-purple-600 dark:text-purple-400',
753
+ };
754
+ if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'm4v'].includes(ext))
755
+ return {
756
+ Icon: FileVideo,
757
+ bg: 'bg-pink-100 dark:bg-pink-950',
758
+ color: 'text-pink-600 dark:text-pink-400',
759
+ };
760
+ if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'].includes(ext))
761
+ return {
762
+ Icon: FileAudio,
763
+ bg: 'bg-yellow-100 dark:bg-yellow-950',
764
+ color: 'text-yellow-600 dark:text-yellow-400',
765
+ };
766
+ if (
767
+ [
768
+ 'js',
769
+ 'ts',
770
+ 'jsx',
771
+ 'tsx',
772
+ 'py',
773
+ 'java',
774
+ 'cs',
775
+ 'cpp',
776
+ 'c',
777
+ 'html',
778
+ 'css',
779
+ 'scss',
780
+ 'json',
781
+ 'xml',
782
+ 'yaml',
783
+ 'yml',
784
+ 'sh',
785
+ 'md',
786
+ ].includes(ext)
787
+ )
788
+ return {
789
+ Icon: FileCode,
790
+ bg: 'bg-cyan-100 dark:bg-cyan-950',
791
+ color: 'text-cyan-600 dark:text-cyan-400',
792
+ };
793
+ if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext))
794
+ return {
795
+ Icon: File,
796
+ bg: 'bg-amber-100 dark:bg-amber-950',
797
+ color: 'text-amber-600 dark:text-amber-400',
798
+ };
799
+ return { Icon: File, bg: 'bg-muted', color: 'text-muted-foreground' };
800
+ }
801
+
802
+ function MaterialIconCell({ material }: { material: Material }) {
803
+ const [faviconError, setFaviconError] = useState(false);
804
+
805
+ if (material.material_type === 'file') {
806
+ const filename = material.file?.filename ?? material.title ?? '';
807
+ const { Icon, bg, color } = getFileIconConfig(filename);
808
+ return (
809
+ <div
810
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ${bg}`}
811
+ >
812
+ <Icon className={`size-4 ${color}`} />
813
+ </div>
814
+ );
815
+ }
816
+
817
+ // Link
818
+ let faviconUrl: string | null = null;
819
+ if (!faviconError && material.url) {
820
+ try {
821
+ const domain = new URL(material.url).hostname;
822
+ faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
823
+ } catch {
824
+ faviconUrl = null;
825
+ }
826
+ }
827
+
828
+ return (
829
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
830
+ {faviconUrl ? (
831
+ <img
832
+ src={faviconUrl}
833
+ alt=""
834
+ className="size-5 rounded-sm"
835
+ onError={() => setFaviconError(true)}
836
+ />
837
+ ) : (
838
+ <Link className="size-4 text-muted-foreground" />
839
+ )}
840
+ </div>
841
+ );
842
+ }
843
+
560
844
  // ── Schemas ───────────────────────────────────────────────────────────────────
561
845
 
562
846
  const getAulaSchema = (t: (key: string) => string) =>
@@ -614,29 +898,7 @@ const getAulaSchema = (t: (key: string) => string) =>
614
898
  }
615
899
  });
616
900
 
617
- const getStudentSchema = (t: (key: string) => string) =>
618
- z.object({
619
- name: z.string().trim().min(3, t('dialogs.studentValidation.nameMin')),
620
- email: z
621
- .string()
622
- .trim()
623
- .max(255)
624
- .refine(
625
- (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
626
- t('dialogs.studentValidation.emailInvalid')
627
- ),
628
- phone: z
629
- .string()
630
- .trim()
631
- .max(50)
632
- .refine(
633
- (value) => !value || /^[0-9+()\s-]{8,20}$/.test(value),
634
- t('dialogs.studentValidation.phoneInvalid')
635
- ),
636
- });
637
-
638
901
  type AulaForm = z.infer<ReturnType<typeof getAulaSchema>>;
639
- type StudentForm = z.infer<ReturnType<typeof getStudentSchema>>;
640
902
 
641
903
  // (mock data removed – all data comes from the API)
642
904
 
@@ -668,7 +930,33 @@ export default function TurmaDetalhePage() {
668
930
  };
669
931
 
670
932
  // ── API ───────────────────────────────────────────────────────────────────
671
- const { request } = useApp();
933
+ const { request, currentLocaleCode } = useApp();
934
+
935
+ const { data: contactTypes = [] } = useQuery<ContactTypeOption[]>({
936
+ queryKey: ['contact-person-contact-types', currentLocaleCode],
937
+ queryFn: async () => {
938
+ const response = await request<{ data: ContactTypeOption[] }>({
939
+ url: '/person-contact-type?pageSize=100',
940
+ method: 'GET',
941
+ });
942
+
943
+ return response.data.data || [];
944
+ },
945
+ placeholderData: (previous) => previous ?? [],
946
+ });
947
+
948
+ const { data: documentTypes = [] } = useQuery<DocumentTypeOption[]>({
949
+ queryKey: ['contact-person-document-types', currentLocaleCode],
950
+ queryFn: async () => {
951
+ const response = await request<{ data: DocumentTypeOption[] }>({
952
+ url: '/person-document-type?pageSize=100',
953
+ method: 'GET',
954
+ });
955
+
956
+ return response.data.data || [];
957
+ },
958
+ placeholderData: (previous) => previous ?? [],
959
+ });
672
960
 
673
961
  const {
674
962
  data: turma,
@@ -718,6 +1006,35 @@ export default function TurmaDetalhePage() {
718
1006
  enabled: !!id,
719
1007
  });
720
1008
 
1009
+ // Load material counts whenever sessions change
1010
+ useEffect(() => {
1011
+ if (!id || aulasQuery.length === 0) return;
1012
+ let cancelled = false;
1013
+ const load = async () => {
1014
+ try {
1015
+ const res = await request<Material[]>({
1016
+ url: `/lms/classes/${id}/materials`,
1017
+ method: 'GET',
1018
+ });
1019
+ if (cancelled) return;
1020
+ const counts: Record<number, number> = {};
1021
+ for (const mat of res.data) {
1022
+ if ((mat as any).course_class_session_id) {
1023
+ const sid = (mat as any).course_class_session_id as number;
1024
+ counts[sid] = (counts[sid] ?? 0) + 1;
1025
+ }
1026
+ }
1027
+ setMaterialCounts(counts);
1028
+ } catch {
1029
+ // ignore errors silently
1030
+ }
1031
+ };
1032
+ void load();
1033
+ return () => {
1034
+ cancelled = true;
1035
+ };
1036
+ }, [id, aulasQuery, request]);
1037
+
721
1038
  const loading = loadingTurma || loadingAlunos || loadingAulas;
722
1039
 
723
1040
  // ── Edit sheet state ──────────────────────────────────────────────────────
@@ -726,13 +1043,35 @@ export default function TurmaDetalhePage() {
726
1043
  const [savingCourse, setSavingCourse] = useState(false);
727
1044
 
728
1045
  // ── Tab state ─────────────────────────────────────────────────────────────
729
- const [activeTab, setActiveTab] = useState('alunos');
1046
+ const [activeTab, setActiveTab] = useState('sessoes');
1047
+ const studentsSectionRef = useRef<HTMLDivElement | null>(null);
730
1048
  const [calendarViewMode, setCalendarViewMode] = useState<
731
1049
  'single' | 'quarter' | 'year' | 'list'
732
1050
  >('single');
733
1051
  const [calendarViewDate, setCalendarViewDate] = useState(() => new Date());
734
1052
  const calendarInitialized = useRef(false);
735
1053
 
1054
+ // ── Global materials tab state ────────────────────────────────────────────
1055
+ const [globalMaterials, setGlobalMaterials] = useState<Material[]>([]);
1056
+ const [loadingGlobalMaterials, setLoadingGlobalMaterials] = useState(false);
1057
+ const [globalUploadQueue, setGlobalUploadQueue] = useState<UploadItem[]>([]);
1058
+ const [globalIsDragOver, setGlobalIsDragOver] = useState(false);
1059
+ const [globalAddLinkOpen, setGlobalAddLinkOpen] = useState(false);
1060
+ const [globalLinkTitle, setGlobalLinkTitle] = useState('');
1061
+ const [globalLinkUrl, setGlobalLinkUrl] = useState('');
1062
+ const [globalSavingLink, setGlobalSavingLink] = useState(false);
1063
+ const [globalFetchingLinkMeta, setGlobalFetchingLinkMeta] = useState(false);
1064
+ const [globalLinkFavicon, setGlobalLinkFavicon] = useState<string | null>(
1065
+ null
1066
+ );
1067
+ const globalLinkTitleEditedRef = useRef(false);
1068
+ const globalLinkMetaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
1069
+ null
1070
+ );
1071
+ const globalLinkMetaAbortRef = useRef<AbortController | null>(null);
1072
+ const globalMaterialFileInputRef = useRef<HTMLInputElement>(null);
1073
+ const globalMaterialsLoadedRef = useRef(false);
1074
+
736
1075
  useEffect(() => {
737
1076
  if (calendarInitialized.current || !turma) return;
738
1077
 
@@ -762,6 +1101,174 @@ export default function TurmaDetalhePage() {
762
1101
  setCalendarViewDate(startOfMonth(start));
763
1102
  }, [turma]);
764
1103
 
1104
+ // Load all materials when global tab activates
1105
+ useEffect(() => {
1106
+ if (
1107
+ activeTab !== 'materiais-global' ||
1108
+ !id ||
1109
+ globalMaterialsLoadedRef.current
1110
+ )
1111
+ return;
1112
+ globalMaterialsLoadedRef.current = true;
1113
+ setLoadingGlobalMaterials(true);
1114
+ void request<Material[]>({
1115
+ url: `/lms/classes/${id}/materials`,
1116
+ method: 'GET',
1117
+ })
1118
+ .then((res) => setGlobalMaterials(res.data))
1119
+ .catch(() => setGlobalMaterials([]))
1120
+ .finally(() => setLoadingGlobalMaterials(false));
1121
+ }, [activeTab, id, request]);
1122
+
1123
+ const resetGlobalLinkForm = () => {
1124
+ setGlobalLinkTitle('');
1125
+ setGlobalLinkUrl('');
1126
+ setGlobalLinkFavicon(null);
1127
+ setGlobalFetchingLinkMeta(false);
1128
+ setGlobalAddLinkOpen(false);
1129
+ globalLinkTitleEditedRef.current = false;
1130
+ if (globalLinkMetaTimerRef.current)
1131
+ clearTimeout(globalLinkMetaTimerRef.current);
1132
+ if (globalLinkMetaAbortRef.current) {
1133
+ globalLinkMetaAbortRef.current.abort();
1134
+ globalLinkMetaAbortRef.current = null;
1135
+ }
1136
+ };
1137
+
1138
+ const handleGlobalLinkUrlChange = (rawUrl: string) => {
1139
+ setGlobalLinkUrl(rawUrl);
1140
+ setGlobalLinkFavicon(null);
1141
+ if (globalLinkMetaTimerRef.current)
1142
+ clearTimeout(globalLinkMetaTimerRef.current);
1143
+ if (globalLinkMetaAbortRef.current) {
1144
+ globalLinkMetaAbortRef.current.abort();
1145
+ globalLinkMetaAbortRef.current = null;
1146
+ }
1147
+ const url = rawUrl.trim();
1148
+ if (!url || !/^https?:\/\//.test(url)) return;
1149
+ try {
1150
+ const domain = new URL(url).hostname;
1151
+ setGlobalLinkFavicon(
1152
+ `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
1153
+ );
1154
+ } catch {
1155
+ /* ignore */
1156
+ }
1157
+ if (globalLinkTitleEditedRef.current) return;
1158
+ setGlobalFetchingLinkMeta(true);
1159
+ globalLinkMetaTimerRef.current = setTimeout(async () => {
1160
+ if (globalLinkTitleEditedRef.current) return;
1161
+ const controller = new AbortController();
1162
+ globalLinkMetaAbortRef.current = controller;
1163
+ try {
1164
+ const res = await fetch(
1165
+ `https://api.microlink.io/?url=${encodeURIComponent(url)}&meta=false`,
1166
+ { signal: controller.signal }
1167
+ );
1168
+ const data = (await res.json()) as { data?: { title?: string | null } };
1169
+ if (data?.data?.title && !globalLinkTitleEditedRef.current)
1170
+ setGlobalLinkTitle(data.data.title);
1171
+ } catch {
1172
+ /* ignore */
1173
+ } finally {
1174
+ setGlobalFetchingLinkMeta(false);
1175
+ }
1176
+ }, 800);
1177
+ };
1178
+
1179
+ const handleGlobalAddLink = async () => {
1180
+ if (!globalLinkUrl.trim()) return;
1181
+ const url = globalLinkUrl.trim();
1182
+ const title = globalLinkTitle.trim() || url;
1183
+ setGlobalSavingLink(true);
1184
+ try {
1185
+ const res = await request<Material>({
1186
+ url: `/lms/classes/${id}/materials`,
1187
+ method: 'POST',
1188
+ data: { title, materialType: 'link', url },
1189
+ });
1190
+ setGlobalMaterials((prev) => [res.data, ...prev]);
1191
+ resetGlobalLinkForm();
1192
+ } catch {
1193
+ toast.error('Não foi possível adicionar o link.');
1194
+ } finally {
1195
+ setGlobalSavingLink(false);
1196
+ }
1197
+ };
1198
+
1199
+ const uploadGlobalFiles = async (files: File[]) => {
1200
+ const baseId = Date.now();
1201
+ const newItems: UploadItem[] = files.map((file, i) => ({
1202
+ id: `g-${baseId}-${i}`,
1203
+ file,
1204
+ progress: 0,
1205
+ status: 'uploading' as const,
1206
+ title: file.name.replace(/\.[^.]+$/, ''),
1207
+ }));
1208
+ setGlobalUploadQueue((prev) => [...prev, ...newItems]);
1209
+ for (const item of newItems) {
1210
+ try {
1211
+ const formData = new FormData();
1212
+ formData.append('file', item.file);
1213
+ const { data: uploaded } = await request<{ id: number }>({
1214
+ method: 'POST',
1215
+ url: '/file',
1216
+ data: formData,
1217
+ headers: { 'Content-Type': 'multipart/form-data' },
1218
+ onUploadProgress: (e: { loaded: number; total?: number }) => {
1219
+ const progress = e.total
1220
+ ? Math.round((e.loaded / e.total) * 100)
1221
+ : 0;
1222
+ setGlobalUploadQueue((prev) =>
1223
+ prev.map((q) => (q.id === item.id ? { ...q, progress } : q))
1224
+ );
1225
+ },
1226
+ });
1227
+ const fileId = Number(uploaded?.id);
1228
+ if (!fileId) throw new Error('Upload failed');
1229
+ const res = await request<Material>({
1230
+ url: `/lms/classes/${id}/materials`,
1231
+ method: 'POST',
1232
+ data: { title: item.title, materialType: 'file', fileId },
1233
+ });
1234
+ setGlobalMaterials((prev) => [res.data, ...prev]);
1235
+ setGlobalUploadQueue((prev) =>
1236
+ prev.map((q) =>
1237
+ q.id === item.id
1238
+ ? { ...q, status: 'done' as const, progress: 100 }
1239
+ : q
1240
+ )
1241
+ );
1242
+ setTimeout(
1243
+ () =>
1244
+ setGlobalUploadQueue((prev) =>
1245
+ prev.filter((q) => q.id !== item.id)
1246
+ ),
1247
+ 1500
1248
+ );
1249
+ } catch {
1250
+ setGlobalUploadQueue((prev) =>
1251
+ prev.map((q) =>
1252
+ q.id === item.id ? { ...q, status: 'error' as const } : q
1253
+ )
1254
+ );
1255
+ toast.error(`Falha ao enviar "${item.file.name}".`);
1256
+ }
1257
+ }
1258
+ };
1259
+
1260
+ const handleDeleteGlobalMaterial = async (materialId: number) => {
1261
+ try {
1262
+ await request({
1263
+ url: `/lms/classes/${id}/materials/${materialId}`,
1264
+ method: 'DELETE',
1265
+ });
1266
+ setGlobalMaterials((prev) => prev.filter((m) => m.id !== materialId));
1267
+ } catch {
1268
+ toast.error('Não foi possível remover o material.');
1269
+ }
1270
+ };
1271
+
765
1272
  // ── Students state ────────────────────────────────────────────────────────
766
1273
  const [alunoSearch, setAlunoSearch] = useState('');
767
1274
  const [selectedAlunos, setSelectedAlunos] = useState<number[]>([]);
@@ -781,8 +1288,10 @@ export default function TurmaDetalhePage() {
781
1288
  const [editStudentSheetOpen, setEditStudentSheetOpen] = useState(false);
782
1289
  const [selectedStudentProfile, setSelectedStudentProfile] =
783
1290
  useState<StudentProfile | null>(null);
1291
+ const [selectedStudentPerson, setSelectedStudentPerson] =
1292
+ useState<ContactPerson | null>(null);
784
1293
  const [loadingStudentProfile, setLoadingStudentProfile] = useState(false);
785
- const [savingStudentProfile, setSavingStudentProfile] = useState(false);
1294
+ const [loadingStudentPerson, setLoadingStudentPerson] = useState(false);
786
1295
 
787
1296
  const { data: pessoasDisponiveis = [], isLoading: searchingPessoas } =
788
1297
  useQuery<Person[]>({
@@ -801,9 +1310,9 @@ export default function TurmaDetalhePage() {
801
1310
 
802
1311
  // ── Calendar state ────────────────────────────────────────────────────────
803
1312
  const [aulaSheetOpen, setAulaSheetOpen] = useState(false);
804
- const [aulaSheetTab, setAulaSheetTab] = useState<'aulas' | 'chamada'>(
805
- 'aulas'
806
- );
1313
+ const [aulaSheetTab, setAulaSheetTab] = useState<
1314
+ 'aulas' | 'chamada' | 'materiais'
1315
+ >('aulas');
807
1316
  const [editingAula, setEditingAula] = useState<Aula | null>(null);
808
1317
  const [savingAula, setSavingAula] = useState(false);
809
1318
  const [instructorOpen, setInstructorOpen] = useState(false);
@@ -811,6 +1320,30 @@ export default function TurmaDetalhePage() {
811
1320
  const [createInstructorDialogOpen, setCreateInstructorDialogOpen] =
812
1321
  useState(false);
813
1322
 
1323
+ // ── Session delete state ──────────────────────────────────────────────────
1324
+ const [aulaToDelete, setAulaToDelete] = useState<Aula | null>(null);
1325
+ const [deleteAulaDialogOpen, setDeleteAulaDialogOpen] = useState(false);
1326
+ const [deletingAula, setDeletingAula] = useState(false);
1327
+
1328
+ // ── Materials state ───────────────────────────────────────────────────────
1329
+ const [materials, setMaterials] = useState<Material[]>([]);
1330
+ const [loadingMaterials, setLoadingMaterials] = useState(false);
1331
+ const [addLinkOpen, setAddLinkOpen] = useState(false);
1332
+ const [newLinkTitle, setNewLinkTitle] = useState('');
1333
+ const [newLinkUrl, setNewLinkUrl] = useState('');
1334
+ const [savingLink, setSavingLink] = useState(false);
1335
+ const [fetchingLinkMeta, setFetchingLinkMeta] = useState(false);
1336
+ const [linkFavicon, setLinkFavicon] = useState<string | null>(null);
1337
+ const linkTitleEditedRef = useRef(false);
1338
+ const linkMetaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1339
+ const linkMetaAbortRef = useRef<AbortController | null>(null);
1340
+ const [materialCounts, setMaterialCounts] = useState<Record<number, number>>(
1341
+ {}
1342
+ );
1343
+ const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
1344
+ const [isDragOver, setIsDragOver] = useState(false);
1345
+ const materialFileInputRef = useRef<HTMLInputElement>(null);
1346
+
814
1347
  const {
815
1348
  data: instructorOptions = [],
816
1349
  isFetching: loadingInstructors,
@@ -964,7 +1497,6 @@ export default function TurmaDetalhePage() {
964
1497
 
965
1498
  // ── Form ──────────────────────────────────────────────────────────────────
966
1499
  const aulaSchema = getAulaSchema(t);
967
- const studentSchema = getStudentSchema(t);
968
1500
 
969
1501
  const aulaForm = useForm<AulaForm>({
970
1502
  resolver: zodResolver(aulaSchema),
@@ -988,15 +1520,6 @@ export default function TurmaDetalhePage() {
988
1520
  const recurrenceDaysOfWeek = aulaForm.watch('recurrenceDaysOfWeek') ?? [];
989
1521
  const showRecurrenceFields = !editingAula || editingAula.isRecurring;
990
1522
 
991
- const editStudentForm = useForm<StudentForm>({
992
- resolver: zodResolver(studentSchema),
993
- defaultValues: {
994
- name: '',
995
- email: '',
996
- phone: '',
997
- },
998
- });
999
-
1000
1523
  const courseForm = useForm<CourseSheetFormValues>({
1001
1524
  resolver: zodResolver(getCourseSheetSchema(tCourses)),
1002
1525
  defaultValues: DEFAULT_COURSE_FORM_VALUES,
@@ -1072,6 +1595,15 @@ export default function TurmaDetalhePage() {
1072
1595
  return map;
1073
1596
  }, [aulasState]);
1074
1597
 
1598
+ const isAttendanceTaken = useCallback(
1599
+ (sessionId: number) => {
1600
+ const session = attendanceMatrix[sessionId];
1601
+ if (!session) return false;
1602
+ return Object.values(session).some(Boolean);
1603
+ },
1604
+ [attendanceMatrix]
1605
+ );
1606
+
1075
1607
  const notifyLmsDataUpdated = () => {
1076
1608
  if (typeof window === 'undefined') return;
1077
1609
 
@@ -1271,45 +1803,57 @@ export default function TurmaDetalhePage() {
1271
1803
  }
1272
1804
  };
1273
1805
 
1274
- const openEditStudentSheet = () => {
1806
+ const openEditStudentSheet = async () => {
1275
1807
  if (!selectedStudentProfile) return;
1276
- editStudentForm.reset({
1277
- name: selectedStudentProfile.nome || '',
1278
- email: selectedStudentProfile.email || '',
1279
- phone: selectedStudentProfile.telefone || '',
1280
- });
1281
- setStudentProfileDialogOpen(false);
1282
- setEditStudentSheetOpen(true);
1808
+
1809
+ setLoadingStudentPerson(true);
1810
+ try {
1811
+ const response = await request<ContactPerson>({
1812
+ url: `/person/${selectedStudentProfile.id}`,
1813
+ method: 'GET',
1814
+ });
1815
+
1816
+ setSelectedStudentPerson(response.data);
1817
+ setStudentProfileDialogOpen(false);
1818
+ setEditStudentSheetOpen(true);
1819
+ } catch {
1820
+ toast.error(t('toasts.error'));
1821
+ } finally {
1822
+ setLoadingStudentPerson(false);
1823
+ }
1283
1824
  };
1284
1825
 
1285
- const handleUpdateStudentProfile = editStudentForm.handleSubmit(
1286
- async (values) => {
1287
- if (!selectedStudentProfile) return;
1826
+ const handleStudentPersonSaved = async (person?: ContactPerson) => {
1827
+ await refetchAlunos();
1288
1828
 
1289
- setSavingStudentProfile(true);
1829
+ const personId = person?.id ?? selectedStudentProfile?.id;
1830
+ if (personId) {
1290
1831
  try {
1291
1832
  const res = await request<StudentProfile>({
1292
- url: `/lms/classes/${id}/students/${selectedStudentProfile.id}`,
1293
- method: 'PATCH',
1294
- data: {
1295
- name: values.name,
1296
- email: values.email,
1297
- phone: values.phone,
1298
- },
1833
+ url: `/lms/classes/${id}/students/${personId}`,
1834
+ method: 'GET',
1299
1835
  });
1300
-
1301
1836
  setSelectedStudentProfile(res.data);
1302
- await refetchAlunos();
1303
- setEditStudentSheetOpen(false);
1304
- setStudentProfileDialogOpen(true);
1305
- toast.success(t('toasts.studentUpdated'));
1306
1837
  } catch {
1307
- toast.error(t('toasts.error'));
1308
- } finally {
1309
- setSavingStudentProfile(false);
1838
+ setSelectedStudentProfile((previous) =>
1839
+ previous && previous.id === personId
1840
+ ? {
1841
+ ...previous,
1842
+ nome: person?.name ?? previous.nome,
1843
+ avatarId:
1844
+ person?.avatar_id !== undefined
1845
+ ? person.avatar_id
1846
+ : previous.avatarId,
1847
+ }
1848
+ : previous
1849
+ );
1310
1850
  }
1311
1851
  }
1312
- );
1852
+
1853
+ setEditStudentSheetOpen(false);
1854
+ setSelectedStudentPerson(null);
1855
+ setStudentProfileDialogOpen(true);
1856
+ };
1313
1857
 
1314
1858
  const getDefaultPresencaList = useCallback(
1315
1859
  () =>
@@ -1347,6 +1891,20 @@ export default function TurmaDetalhePage() {
1347
1891
  [alunos, getDefaultPresencaList, id, request]
1348
1892
  );
1349
1893
 
1894
+ const resetLinkForm = () => {
1895
+ setNewLinkTitle('');
1896
+ setNewLinkUrl('');
1897
+ setLinkFavicon(null);
1898
+ setFetchingLinkMeta(false);
1899
+ setAddLinkOpen(false);
1900
+ linkTitleEditedRef.current = false;
1901
+ if (linkMetaTimerRef.current) clearTimeout(linkMetaTimerRef.current);
1902
+ if (linkMetaAbortRef.current) {
1903
+ linkMetaAbortRef.current.abort();
1904
+ linkMetaAbortRef.current = null;
1905
+ }
1906
+ };
1907
+
1350
1908
  const openAulaSheet = (aula?: Aula, options?: OpenAulaSheetOptions) => {
1351
1909
  setAulaSheetTab(options?.initialTab ?? 'aulas');
1352
1910
 
@@ -1367,6 +1925,19 @@ export default function TurmaDetalhePage() {
1367
1925
  applyScope: 'single',
1368
1926
  });
1369
1927
  void loadPresencaForAula(aula);
1928
+ // Load materials for this session
1929
+ setMaterials([]);
1930
+ setUploadQueue([]);
1931
+ resetLinkForm();
1932
+ setLoadingMaterials(true);
1933
+ request<Material[]>({
1934
+ url: `/lms/classes/${id}/materials`,
1935
+ method: 'GET',
1936
+ params: { sessionId: aula.id },
1937
+ })
1938
+ .then((res) => setMaterials(res.data))
1939
+ .catch(() => setMaterials([]))
1940
+ .finally(() => setLoadingMaterials(false));
1370
1941
  } else {
1371
1942
  setEditingAula(null);
1372
1943
  aulaForm.reset({
@@ -1385,6 +1956,9 @@ export default function TurmaDetalhePage() {
1385
1956
  ...options?.prefill,
1386
1957
  });
1387
1958
  setPresencaList(options?.attendance ?? getDefaultPresencaList());
1959
+ setMaterials([]);
1960
+ setUploadQueue([]);
1961
+ resetLinkForm();
1388
1962
  }
1389
1963
 
1390
1964
  setAulaSheetOpen(true);
@@ -1448,6 +2022,17 @@ export default function TurmaDetalhePage() {
1448
2022
  })),
1449
2023
  },
1450
2024
  });
2025
+ // Update local attendanceMatrix so the sessions list reflects the new state immediately
2026
+ const newSessionMatrix: Record<number, boolean> = {};
2027
+ for (const p of presencaList) {
2028
+ if (p.selecionado) {
2029
+ newSessionMatrix[p.alunoId] = p.presente;
2030
+ }
2031
+ }
2032
+ setAttendanceMatrix((prev) => ({
2033
+ ...prev,
2034
+ [sessionId]: newSessionMatrix,
2035
+ }));
1451
2036
  };
1452
2037
 
1453
2038
  const handleSaveAula = aulaForm.handleSubmit(async (data) => {
@@ -1520,6 +2105,7 @@ export default function TurmaDetalhePage() {
1520
2105
  [sessionId]: { ...(prev[sessionId] ?? {}), [studentId]: next },
1521
2106
  }));
1522
2107
  setSavingAttendanceCells((prev) => new Set(prev).add(cellKey));
2108
+ const savingToastId = toast.loading(t('toasts.attendanceSaving'));
1523
2109
 
1524
2110
  try {
1525
2111
  await request({
@@ -1527,13 +2113,14 @@ export default function TurmaDetalhePage() {
1527
2113
  method: 'POST',
1528
2114
  data: { attendance: sessionAttendance },
1529
2115
  });
2116
+ toast.success(t('toasts.attendanceSaved'), { id: savingToastId });
1530
2117
  } catch {
1531
2118
  // Rollback on error
1532
2119
  setAttendanceMatrix((prev) => ({
1533
2120
  ...prev,
1534
2121
  [sessionId]: { ...(prev[sessionId] ?? {}), [studentId]: current },
1535
2122
  }));
1536
- toast.error(t('toasts.error'));
2123
+ toast.error(t('toasts.error'), { id: savingToastId });
1537
2124
  } finally {
1538
2125
  setSavingAttendanceCells((prev) => {
1539
2126
  const updated = new Set(prev);
@@ -1608,61 +2195,254 @@ export default function TurmaDetalhePage() {
1608
2195
  }
1609
2196
  };
1610
2197
 
1611
- // ── KPIs ──────────────────────────────────────────────────────────────────
1612
- const now = new Date();
1613
- const completedSessions = aulasState.filter(
1614
- (aula) => getSessionEndDate(aula) < now
1615
- );
1616
- const upcomingSessions = aulasState.filter(
1617
- (aula) => getSessionEndDate(aula) >= now
1618
- );
1619
- const nextSession = upcomingSessions[0] ?? null;
1620
- const completedSessionsCount = completedSessions.length;
1621
- const totalSessionsCount = aulasState.length;
1622
- const capacity = turma?.capacity ?? 0;
1623
- const occupiedSeats = alunos.length;
1624
- const availableSeats = Math.max(capacity - occupiedSeats, 0);
1625
- const occupancyRate =
1626
- capacity > 0 ? Math.round((occupiedSeats / capacity) * 100) : 0;
1627
- const primaryInstructor =
1628
- nextSession?.instructorName?.trim() ||
1629
- aulasState
1630
- .find((aula) => aula.instructorName?.trim())
1631
- ?.instructorName?.trim() ||
1632
- 'Nao definido';
1633
- const nextSessionStartsAt = nextSession
1634
- ? getSessionStartDate(nextSession)
1635
- : null;
1636
- const nextSessionSummary = nextSessionStartsAt
1637
- ? format(nextSessionStartsAt, "dd/MM 'as' HH:mm", { locale: dateLocale })
1638
- : 'Nenhuma aula futura';
1639
- const nextSessionSupportText = nextSession
1640
- ? nextSession.titulo
1641
- : 'Agende a proxima aula para manter a turma em movimento.';
2198
+ const handleDeleteAula = async () => {
2199
+ if (!aulaToDelete) return;
2200
+ setDeletingAula(true);
2201
+ try {
2202
+ await request({
2203
+ url: `/lms/classes/${id}/sessions/${aulaToDelete.id}`,
2204
+ method: 'DELETE',
2205
+ });
2206
+ setAulasState((prev) => prev.filter((a) => a.id !== aulaToDelete.id));
2207
+ void refetchAulas();
2208
+ notifyLmsDataUpdated();
2209
+ setDeleteAulaDialogOpen(false);
2210
+ setAulaToDelete(null);
2211
+ toast.success('Aula removida com sucesso.');
2212
+ } catch {
2213
+ toast.error(t('toasts.error'));
2214
+ } finally {
2215
+ setDeletingAula(false);
2216
+ }
2217
+ };
1642
2218
 
1643
- const kpis: KpiCardItem[] = [
1644
- {
1645
- key: 'enrolled-students',
1646
- title: t('kpis.enrolledStudents.label'),
1647
- value: occupiedSeats,
1648
- description: t('kpis.enrolledStudents.sub', {
1649
- vagas: capacity,
1650
- }),
1651
- icon: Users,
1652
- iconContainerClassName: 'bg-orange-500/10 text-orange-700',
1653
- accentClassName: 'from-orange-500/25 via-amber-500/10 to-transparent',
1654
- layout: 'compact',
1655
- },
1656
- {
1657
- key: 'occupancy-rate',
1658
- title: t('kpis.occupancyRate.label'),
1659
- value: capacity > 0 ? `${occupancyRate}%` : '—',
1660
- description:
1661
- capacity > 0 && availableSeats > 0
1662
- ? t('kpis.occupancyRate.subFree', {
1663
- count: availableSeats,
1664
- })
1665
- : capacity > 0
2219
+ const uploadFiles = async (files: File[]) => {
2220
+ if (!editingAula) return;
2221
+ const baseId = Date.now();
2222
+ const newItems: UploadItem[] = files.map((file, i) => ({
2223
+ id: `${baseId}-${i}`,
2224
+ file,
2225
+ progress: 0,
2226
+ status: 'uploading' as const,
2227
+ title: file.name.replace(/\.[^.]+$/, ''),
2228
+ }));
2229
+ setUploadQueue((prev) => [...prev, ...newItems]);
2230
+
2231
+ for (const item of newItems) {
2232
+ try {
2233
+ const formData = new FormData();
2234
+ formData.append('file', item.file);
2235
+ const { data: uploaded } = await request<{ id: number }>({
2236
+ method: 'POST',
2237
+ url: '/file',
2238
+ data: formData,
2239
+ headers: { 'Content-Type': 'multipart/form-data' },
2240
+ onUploadProgress: (e: { loaded: number; total?: number }) => {
2241
+ const progress = e.total
2242
+ ? Math.round((e.loaded / e.total) * 100)
2243
+ : 0;
2244
+ setUploadQueue((prev) =>
2245
+ prev.map((q) => (q.id === item.id ? { ...q, progress } : q))
2246
+ );
2247
+ },
2248
+ });
2249
+ const fileId = Number(uploaded?.id);
2250
+ if (!fileId) throw new Error('Upload failed');
2251
+
2252
+ const res = await request<Material>({
2253
+ url: `/lms/classes/${id}/materials`,
2254
+ method: 'POST',
2255
+ data: {
2256
+ title: item.title,
2257
+ materialType: 'file',
2258
+ fileId,
2259
+ sessionId: editingAula.id,
2260
+ },
2261
+ });
2262
+ setMaterials((prev) => [...prev, res.data]);
2263
+ setMaterialCounts((prev) => ({
2264
+ ...prev,
2265
+ [editingAula.id]: (prev[editingAula.id] ?? 0) + 1,
2266
+ }));
2267
+ setUploadQueue((prev) =>
2268
+ prev.map((q) =>
2269
+ q.id === item.id
2270
+ ? { ...q, status: 'done' as const, progress: 100 }
2271
+ : q
2272
+ )
2273
+ );
2274
+ setTimeout(
2275
+ () => setUploadQueue((prev) => prev.filter((q) => q.id !== item.id)),
2276
+ 1500
2277
+ );
2278
+ } catch {
2279
+ setUploadQueue((prev) =>
2280
+ prev.map((q) =>
2281
+ q.id === item.id ? { ...q, status: 'error' as const } : q
2282
+ )
2283
+ );
2284
+ toast.error(`Falha ao enviar "${item.file.name}".`);
2285
+ }
2286
+ }
2287
+ };
2288
+
2289
+ const handleLinkUrlChange = (rawUrl: string) => {
2290
+ setNewLinkUrl(rawUrl);
2291
+ setLinkFavicon(null);
2292
+
2293
+ if (linkMetaTimerRef.current) clearTimeout(linkMetaTimerRef.current);
2294
+ if (linkMetaAbortRef.current) {
2295
+ linkMetaAbortRef.current.abort();
2296
+ linkMetaAbortRef.current = null;
2297
+ }
2298
+
2299
+ const url = rawUrl.trim();
2300
+ if (!url || !/^https?:\/\//.test(url)) return;
2301
+
2302
+ // Show favicon immediately from domain
2303
+ try {
2304
+ const domain = new URL(url).hostname;
2305
+ setLinkFavicon(
2306
+ `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
2307
+ );
2308
+ } catch {
2309
+ // invalid URL — ignore
2310
+ }
2311
+
2312
+ // Debounce title fetch (only if user hasn't typed a title manually)
2313
+ if (linkTitleEditedRef.current) return;
2314
+ setFetchingLinkMeta(true);
2315
+ linkMetaTimerRef.current = setTimeout(async () => {
2316
+ if (linkTitleEditedRef.current) return;
2317
+ setFetchingLinkMeta(true);
2318
+ const controller = new AbortController();
2319
+ linkMetaAbortRef.current = controller;
2320
+ try {
2321
+ const res = await fetch(
2322
+ `https://api.microlink.io/?url=${encodeURIComponent(url)}&meta=false`,
2323
+ { signal: controller.signal }
2324
+ );
2325
+ const data = (await res.json()) as {
2326
+ data?: { title?: string | null };
2327
+ };
2328
+ if (data?.data?.title && !linkTitleEditedRef.current) {
2329
+ setNewLinkTitle(data.data.title);
2330
+ }
2331
+ } catch {
2332
+ // abort or network error — ignore silently
2333
+ } finally {
2334
+ setFetchingLinkMeta(false);
2335
+ }
2336
+ }, 800);
2337
+ };
2338
+
2339
+ const handleAddLink = async () => {
2340
+ if (!editingAula || !newLinkUrl.trim()) return;
2341
+ const url = newLinkUrl.trim();
2342
+ const title = newLinkTitle.trim() || url;
2343
+ setSavingLink(true);
2344
+ try {
2345
+ const res = await request<Material>({
2346
+ url: `/lms/classes/${id}/materials`,
2347
+ method: 'POST',
2348
+ data: {
2349
+ title,
2350
+ materialType: 'link',
2351
+ url,
2352
+ sessionId: editingAula.id,
2353
+ },
2354
+ });
2355
+ setMaterials((prev) => [...prev, res.data]);
2356
+ setMaterialCounts((prev) => ({
2357
+ ...prev,
2358
+ [editingAula.id]: (prev[editingAula.id] ?? 0) + 1,
2359
+ }));
2360
+ resetLinkForm();
2361
+ } catch {
2362
+ toast.error('Não foi possível adicionar o link.');
2363
+ } finally {
2364
+ setSavingLink(false);
2365
+ }
2366
+ };
2367
+
2368
+ const handleDeleteMaterial = async (materialId: number) => {
2369
+ try {
2370
+ await request({
2371
+ url: `/lms/classes/${id}/materials/${materialId}`,
2372
+ method: 'DELETE',
2373
+ });
2374
+ setMaterials((prev) => prev.filter((m) => m.id !== materialId));
2375
+ if (editingAula) {
2376
+ setMaterialCounts((prev) => ({
2377
+ ...prev,
2378
+ [editingAula.id]: Math.max((prev[editingAula.id] ?? 1) - 1, 0),
2379
+ }));
2380
+ }
2381
+ } catch {
2382
+ toast.error('Não foi possível remover o material.');
2383
+ }
2384
+ };
2385
+
2386
+ // ── KPIs ──────────────────────────────────────────────────────────────────
2387
+ const now = new Date();
2388
+ const completedSessions = aulasState.filter(
2389
+ (aula) => getSessionEndDate(aula) < now
2390
+ );
2391
+ const upcomingSessions = aulasState.filter(
2392
+ (aula) => getSessionEndDate(aula) >= now
2393
+ );
2394
+ const nextSession = upcomingSessions[0] ?? null;
2395
+ const completedSessionsCount = completedSessions.length;
2396
+ const totalSessionsCount = aulasState.length;
2397
+ const capacity = turma?.capacity ?? 0;
2398
+ const occupiedSeats = alunos.length;
2399
+ const availableSeats = Math.max(capacity - occupiedSeats, 0);
2400
+ const occupancyRate =
2401
+ capacity > 0 ? Math.round((occupiedSeats / capacity) * 100) : 0;
2402
+ const primaryInstructor =
2403
+ nextSession?.instructorName?.trim() ||
2404
+ aulasState
2405
+ .find((aula) => aula.instructorName?.trim())
2406
+ ?.instructorName?.trim() ||
2407
+ 'Nao definido';
2408
+ const primaryInstructorAvatarId =
2409
+ nextSession?.instructorAvatarId ??
2410
+ aulasState.find((aula) => aula.instructorAvatarId)?.instructorAvatarId ??
2411
+ turma?.instructorAvatarId ??
2412
+ null;
2413
+ const nextSessionStartsAt = nextSession
2414
+ ? getSessionStartDate(nextSession)
2415
+ : null;
2416
+ const nextSessionSummary = nextSessionStartsAt
2417
+ ? format(nextSessionStartsAt, "dd/MM 'as' HH:mm", { locale: dateLocale })
2418
+ : 'Nenhuma aula futura';
2419
+ const nextSessionSupportText = nextSession
2420
+ ? nextSession.titulo
2421
+ : 'Agende a proxima aula para manter a turma em movimento.';
2422
+
2423
+ const kpis: KpiCardItem[] = [
2424
+ {
2425
+ key: 'enrolled-students',
2426
+ title: t('kpis.enrolledStudents.label'),
2427
+ value: occupiedSeats,
2428
+ description: t('kpis.enrolledStudents.sub', {
2429
+ vagas: capacity,
2430
+ }),
2431
+ icon: Users,
2432
+ iconContainerClassName: 'bg-orange-500/10 text-orange-700',
2433
+ accentClassName: 'from-orange-500/25 via-amber-500/10 to-transparent',
2434
+ layout: 'compact',
2435
+ },
2436
+ {
2437
+ key: 'occupancy-rate',
2438
+ title: t('kpis.occupancyRate.label'),
2439
+ value: capacity > 0 ? `${occupancyRate}%` : '—',
2440
+ description:
2441
+ capacity > 0 && availableSeats > 0
2442
+ ? t('kpis.occupancyRate.subFree', {
2443
+ count: availableSeats,
2444
+ })
2445
+ : capacity > 0
1666
2446
  ? t('kpis.occupancyRate.subFull')
1667
2447
  : 'Capacidade nao definida',
1668
2448
  icon: BarChart3,
@@ -1725,6 +2505,8 @@ export default function TurmaDetalhePage() {
1725
2505
  const classTitle = turma?.courseTitle ?? turma?.curso ?? '';
1726
2506
  const classCode = turma?.code ?? turma?.codigo ?? '';
1727
2507
  const courseId = turma?.courseId ?? turma?.cursoId;
2508
+ const courseLogoFileId =
2509
+ courseDetail?.logoFileId ?? turma?.logoFileId ?? null;
1728
2510
  const startDate = turma?.startDate ?? turma?.dataInicio;
1729
2511
  const endDate = turma?.endDate ?? turma?.dataFim;
1730
2512
  const schedule =
@@ -1751,10 +2533,30 @@ export default function TurmaDetalhePage() {
1751
2533
  openAulaSheet();
1752
2534
  };
1753
2535
 
2536
+ const handleViewStudents = (): void => {
2537
+ setActiveTab('alunos');
2538
+ requestAnimationFrame(() => {
2539
+ studentsSectionRef.current?.scrollIntoView({
2540
+ behavior: 'smooth',
2541
+ block: 'start',
2542
+ });
2543
+ });
2544
+ };
2545
+
1754
2546
  return (
1755
2547
  <Page>
1756
2548
  <PageHeader
1757
- title={classTitle}
2549
+ title={
2550
+ <span className="flex min-w-0 items-center gap-3">
2551
+ <CourseAvatar
2552
+ fileId={courseLogoFileId}
2553
+ title={classTitle}
2554
+ className="size-10 rounded-lg"
2555
+ iconSize="size-5"
2556
+ />
2557
+ <span className="min-w-0 truncate">{classTitle}</span>
2558
+ </span>
2559
+ }
1758
2560
  breadcrumbs={[
1759
2561
  {
1760
2562
  label: t('breadcrumbs.home'),
@@ -1877,13 +2679,22 @@ export default function TurmaDetalhePage() {
1877
2679
  className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]"
1878
2680
  >
1879
2681
  {/* ── Left: Tabs ─────────────────────────────────────────────── */}
1880
- <div className="min-w-0">
2682
+ <div ref={studentsSectionRef} className="min-w-0 scroll-mt-24">
1881
2683
  <Tabs
1882
2684
  value={activeTab}
1883
2685
  onValueChange={setActiveTab}
1884
2686
  className="w-full"
1885
2687
  >
1886
- <TabsList className="mb-4 h-auto grid w-full grid-cols-3 rounded-lg bg-muted/80 p-1">
2688
+ <TabsList className="mb-4 h-auto grid w-full grid-cols-5 rounded-lg bg-muted/80 p-1">
2689
+ <TabsTrigger value="sessoes" className="gap-2">
2690
+ <CalendarIcon className="size-4" />
2691
+ Sessões
2692
+ {!loading && totalSessionsCount > 0 && (
2693
+ <span className="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
2694
+ {totalSessionsCount}
2695
+ </span>
2696
+ )}
2697
+ </TabsTrigger>
1887
2698
  <TabsTrigger value="alunos" className="gap-2">
1888
2699
  <Users className="size-4" />
1889
2700
  {t('tabs.students')}
@@ -1911,8 +2722,342 @@ export default function TurmaDetalhePage() {
1911
2722
  </span>
1912
2723
  )}
1913
2724
  </TabsTrigger>
2725
+ <TabsTrigger value="materiais-global" className="gap-2">
2726
+ <Paperclip className="size-4" />
2727
+ Materiais
2728
+ {globalMaterials.length > 0 && (
2729
+ <span className="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
2730
+ {globalMaterials.length}
2731
+ </span>
2732
+ )}
2733
+ </TabsTrigger>
1914
2734
  </TabsList>
1915
2735
 
2736
+ {/* ── Tab Sessões ─────────────────────────────────────────────── */}
2737
+ <TabsContent value="sessoes" className="mt-0">
2738
+ {/* Toolbar */}
2739
+ <div className="mb-4 flex items-center justify-between gap-3">
2740
+ <p className="text-sm text-muted-foreground">
2741
+ {loading ? (
2742
+ <span className="inline-block h-4 w-32 animate-pulse rounded bg-muted" />
2743
+ ) : (
2744
+ `${aulasState.length} aula${aulasState.length !== 1 ? 's' : ''} cadastrada${aulasState.length !== 1 ? 's' : ''}`
2745
+ )}
2746
+ </p>
2747
+ <Button
2748
+ type="button"
2749
+ variant="outline"
2750
+ className="gap-2"
2751
+ onClick={() => openAulaSheet()}
2752
+ >
2753
+ <Plus className="size-4" />
2754
+ {t('actions.newLesson')}
2755
+ </Button>
2756
+ </div>
2757
+
2758
+ {/* Content */}
2759
+ {loading ? (
2760
+ <div className="space-y-2">
2761
+ {Array.from({ length: 5 }).map((_, i) => (
2762
+ <div
2763
+ key={i}
2764
+ className="flex items-center gap-3 rounded-lg border border-border/70 p-3"
2765
+ >
2766
+ <div className="h-2.5 w-2.5 animate-pulse rounded-full bg-muted" />
2767
+ <div className="h-4 w-48 animate-pulse rounded bg-muted" />
2768
+ <div className="ml-auto h-4 w-24 animate-pulse rounded bg-muted" />
2769
+ </div>
2770
+ ))}
2771
+ </div>
2772
+ ) : aulasState.length === 0 ? (
2773
+ <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
2774
+ <div className="flex flex-col items-center gap-3">
2775
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/60 text-muted-foreground">
2776
+ <CalendarIcon className="size-5" />
2777
+ </div>
2778
+ <div>
2779
+ <p className="text-sm font-semibold">
2780
+ {t('calendar.empty.title')}
2781
+ </p>
2782
+ <p className="mt-0.5 text-xs text-muted-foreground">
2783
+ {t('calendar.empty.description')}
2784
+ </p>
2785
+ </div>
2786
+ <Button
2787
+ size="sm"
2788
+ variant="outline"
2789
+ className="gap-2"
2790
+ onClick={() => openAulaSheet()}
2791
+ >
2792
+ <Plus className="size-3.5" />
2793
+ {t('actions.newLesson')}
2794
+ </Button>
2795
+ </div>
2796
+ </div>
2797
+ ) : (
2798
+ <div className="overflow-x-auto rounded-xl border border-border/60">
2799
+ <Table>
2800
+ <TableHeader>
2801
+ <TableRow className="bg-muted/40 hover:bg-muted/40">
2802
+ <TableHead className="w-8 text-center text-xs">
2803
+ #
2804
+ </TableHead>
2805
+ <TableHead>Sessão</TableHead>
2806
+ <TableHead className="w-[120px]">Data</TableHead>
2807
+ <TableHead className="w-[105px]">Horário</TableHead>
2808
+ <TableHead className="w-[90px]">Tipo</TableHead>
2809
+ <TableHead>Instrutor</TableHead>
2810
+ <TableHead className="w-[90px] text-center">
2811
+ Chamada
2812
+ </TableHead>
2813
+ <TableHead className="w-[80px] text-center">
2814
+ Materiais
2815
+ </TableHead>
2816
+ <TableHead className="w-[90px]">Link</TableHead>
2817
+ <TableHead className="w-[44px]" />
2818
+ </TableRow>
2819
+ </TableHeader>
2820
+ <TableBody>
2821
+ {aulasState.map((aula, index) => {
2822
+ const sessionColor =
2823
+ aula.cor || aula.color || '#3b82f6';
2824
+ const isPast = getSessionEndDate(aula) < now;
2825
+ const attendanceTaken = isAttendanceTaken(aula.id);
2826
+ const rawLink = aula.meetingUrl || aula.local || '';
2827
+ const meetingUrl =
2828
+ rawLink.startsWith('http://') ||
2829
+ rawLink.startsWith('https://')
2830
+ ? rawLink
2831
+ : undefined;
2832
+ return (
2833
+ <TableRow
2834
+ key={aula.id}
2835
+ className="cursor-pointer hover:bg-muted/30"
2836
+ onClick={() => openAulaSheet(aula)}
2837
+ >
2838
+ {/* # */}
2839
+ <TableCell className="text-center text-xs tabular-nums text-muted-foreground">
2840
+ {index + 1}
2841
+ </TableCell>
2842
+
2843
+ {/* Sessão: cor + título + ementa */}
2844
+ <TableCell>
2845
+ <div className="flex items-center gap-2">
2846
+ <span
2847
+ className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
2848
+ style={{ backgroundColor: sessionColor }}
2849
+ />
2850
+ <div className="min-w-0">
2851
+ <p
2852
+ className={cn(
2853
+ 'truncate text-sm font-medium',
2854
+ isPast && 'text-muted-foreground'
2855
+ )}
2856
+ >
2857
+ {aula.titulo}
2858
+ </p>
2859
+ {aula.descricao && (
2860
+ <p className="truncate text-[11px] text-muted-foreground">
2861
+ {aula.descricao}
2862
+ </p>
2863
+ )}
2864
+ {aula.isRecurring && (
2865
+ <p className="text-[10px] text-muted-foreground/70">
2866
+ Recorrente
2867
+ </p>
2868
+ )}
2869
+ </div>
2870
+ </div>
2871
+ </TableCell>
2872
+
2873
+ {/* Data */}
2874
+ <TableCell>
2875
+ <span
2876
+ className={cn(
2877
+ 'text-sm tabular-nums',
2878
+ isPast && 'text-muted-foreground'
2879
+ )}
2880
+ >
2881
+ {format(
2882
+ parseSessionDate(aula.data),
2883
+ 'dd/MM/yyyy',
2884
+ { locale: dateLocale }
2885
+ )}
2886
+ </span>
2887
+ </TableCell>
2888
+
2889
+ {/* Horário */}
2890
+ <TableCell className="tabular-nums text-sm text-muted-foreground">
2891
+ {aula.horaInicio} – {aula.horaFim}
2892
+ </TableCell>
2893
+
2894
+ {/* Tipo */}
2895
+ <TableCell>
2896
+ <Badge
2897
+ variant="outline"
2898
+ className="gap-1 text-[11px]"
2899
+ >
2900
+ {aula.tipo === 'online' ? (
2901
+ <Video className="size-3" />
2902
+ ) : (
2903
+ <MapPin className="size-3" />
2904
+ )}
2905
+ {tClasses(`type.${aula.tipo}`)}
2906
+ </Badge>
2907
+ </TableCell>
2908
+
2909
+ {/* Instrutor */}
2910
+ <TableCell>
2911
+ {aula.instructorName ? (
2912
+ <div className="flex min-w-0 items-center gap-2">
2913
+ <Avatar className="size-7 shrink-0">
2914
+ <AvatarImage
2915
+ src={getPersonAvatarUrl(
2916
+ aula.instructorAvatarId
2917
+ )}
2918
+ alt={aula.instructorName}
2919
+ />
2920
+ <AvatarFallback className="bg-primary/10 text-[9px] font-medium text-primary">
2921
+ {getPersonInitials(
2922
+ aula.instructorName
2923
+ )}
2924
+ </AvatarFallback>
2925
+ </Avatar>
2926
+ <span className="truncate text-sm">
2927
+ {aula.instructorName}
2928
+ </span>
2929
+ </div>
2930
+ ) : (
2931
+ <span className="text-sm text-muted-foreground">
2932
+
2933
+ </span>
2934
+ )}
2935
+ </TableCell>
2936
+
2937
+ {/* Chamada */}
2938
+ <TableCell className="text-center">
2939
+ <Badge
2940
+ variant="outline"
2941
+ className={cn(
2942
+ 'text-[10px]',
2943
+ attendanceTaken
2944
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-400'
2945
+ : 'text-muted-foreground'
2946
+ )}
2947
+ >
2948
+ {attendanceTaken ? 'Feita' : 'Pendente'}
2949
+ </Badge>
2950
+ </TableCell>
2951
+
2952
+ {/* Materiais */}
2953
+ <TableCell className="text-center">
2954
+ {(materialCounts[aula.id] ?? 0) > 0 ? (
2955
+ <Badge
2956
+ variant="outline"
2957
+ className="gap-1 text-[10px] border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-400"
2958
+ >
2959
+ <Paperclip className="size-3" />
2960
+ {materialCounts[aula.id]}
2961
+ </Badge>
2962
+ ) : (
2963
+ <span className="text-xs text-muted-foreground">
2964
+
2965
+ </span>
2966
+ )}
2967
+ </TableCell>
2968
+
2969
+ {/* Link */}
2970
+ <TableCell>
2971
+ {meetingUrl ? (
2972
+ <div
2973
+ className="flex items-center gap-1"
2974
+ onClick={(e) => e.stopPropagation()}
2975
+ >
2976
+ <TooltipProvider delayDuration={200}>
2977
+ <Tooltip>
2978
+ <TooltipTrigger asChild>
2979
+ <a
2980
+ href={meetingUrl}
2981
+ target="_blank"
2982
+ rel="noopener noreferrer"
2983
+ className="flex items-center gap-1.5 text-blue-600 hover:underline"
2984
+ >
2985
+ <MeetingPlatformIcon
2986
+ url={meetingUrl}
2987
+ className="size-4"
2988
+ />
2989
+ <span className="text-xs">
2990
+ Entrar
2991
+ </span>
2992
+ </a>
2993
+ </TooltipTrigger>
2994
+ <TooltipContent
2995
+ side="top"
2996
+ className="max-w-[260px] break-all text-xs"
2997
+ >
2998
+ {meetingUrl}
2999
+ </TooltipContent>
3000
+ </Tooltip>
3001
+ </TooltipProvider>
3002
+ <CopyButton
3003
+ value={meetingUrl}
3004
+ className="size-6 shrink-0"
3005
+ />
3006
+ </div>
3007
+ ) : (
3008
+ <span className="text-muted-foreground">
3009
+
3010
+ </span>
3011
+ )}
3012
+ </TableCell>
3013
+
3014
+ {/* Ações */}
3015
+ <TableCell>
3016
+ <DropdownMenu>
3017
+ <DropdownMenuTrigger asChild>
3018
+ <Button
3019
+ variant="ghost"
3020
+ size="icon"
3021
+ className="size-7"
3022
+ onClick={(e) => e.stopPropagation()}
3023
+ >
3024
+ <MoreHorizontal className="size-4" />
3025
+ </Button>
3026
+ </DropdownMenuTrigger>
3027
+ <DropdownMenuContent align="end">
3028
+ <DropdownMenuItem
3029
+ onClick={(e) => {
3030
+ e.stopPropagation();
3031
+ openAulaSheet(aula);
3032
+ }}
3033
+ >
3034
+ <Pencil className="mr-2 size-4" />
3035
+ Editar
3036
+ </DropdownMenuItem>
3037
+ <DropdownMenuSeparator />
3038
+ <DropdownMenuItem
3039
+ className="text-destructive focus:text-destructive"
3040
+ onClick={(e) => {
3041
+ e.stopPropagation();
3042
+ setAulaToDelete(aula);
3043
+ setDeleteAulaDialogOpen(true);
3044
+ }}
3045
+ >
3046
+ <Trash2 className="mr-2 size-4" />
3047
+ Remover
3048
+ </DropdownMenuItem>
3049
+ </DropdownMenuContent>
3050
+ </DropdownMenu>
3051
+ </TableCell>
3052
+ </TableRow>
3053
+ );
3054
+ })}
3055
+ </TableBody>
3056
+ </Table>
3057
+ </div>
3058
+ )}
3059
+ </TabsContent>
3060
+
1916
3061
  {/* ── Tab Alunos ───────────────────────────────────────────── */}
1917
3062
  <TabsContent value="alunos" className="mt-0">
1918
3063
  {/* Actions bar */}
@@ -2002,6 +3147,12 @@ export default function TurmaDetalhePage() {
2002
3147
  <div className="flex w-full items-center gap-3">
2003
3148
  <Checkbox checked={isSelected} />
2004
3149
  <Avatar className="size-8">
3150
+ <AvatarImage
3151
+ src={getPersonAvatarUrl(
3152
+ pessoa.avatarId
3153
+ )}
3154
+ alt={pessoa.nome}
3155
+ />
2005
3156
  <AvatarFallback className="text-[10px]">
2006
3157
  {pessoa.nome
2007
3158
  .split(' ')
@@ -2204,6 +3355,10 @@ export default function TurmaDetalhePage() {
2204
3355
  className="shrink-0"
2205
3356
  />
2206
3357
  <Avatar className="size-8 shrink-0">
3358
+ <AvatarImage
3359
+ src={getPersonAvatarUrl(aluno.avatarId)}
3360
+ alt={aluno.nome}
3361
+ />
2207
3362
  <AvatarFallback className="bg-gradient-to-br from-blue-100 to-blue-200 text-[11px] font-medium text-blue-700">
2208
3363
  {getPersonInitials(aluno.nome)}
2209
3364
  </AvatarFallback>
@@ -2465,7 +3620,28 @@ export default function TurmaDetalhePage() {
2465
3620
  {aula.meetingUrl || aula.local || '—'}
2466
3621
  </TableCell>
2467
3622
  <TableCell className="text-sm text-muted-foreground">
2468
- {aula.instructorName || '—'}
3623
+ {aula.instructorName ? (
3624
+ <div className="flex min-w-0 items-center gap-2">
3625
+ <Avatar className="size-7 shrink-0">
3626
+ <AvatarImage
3627
+ src={getPersonAvatarUrl(
3628
+ aula.instructorAvatarId
3629
+ )}
3630
+ alt={aula.instructorName}
3631
+ />
3632
+ <AvatarFallback className="bg-primary/10 text-[9px] font-medium text-primary">
3633
+ {getPersonInitials(
3634
+ aula.instructorName
3635
+ )}
3636
+ </AvatarFallback>
3637
+ </Avatar>
3638
+ <span className="truncate">
3639
+ {aula.instructorName}
3640
+ </span>
3641
+ </div>
3642
+ ) : (
3643
+ '—'
3644
+ )}
2469
3645
  </TableCell>
2470
3646
  <TableCell>
2471
3647
  <Button
@@ -2681,7 +3857,7 @@ export default function TurmaDetalhePage() {
2681
3857
  <Table>
2682
3858
  <TableHeader>
2683
3859
  <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">
3860
+ <TableHead className="sticky left-0 z-10 min-w-[260px] bg-muted/40 font-semibold">
2685
3861
  {t('tabs.students')}
2686
3862
  </TableHead>
2687
3863
  {aulasState.map((aula) => {
@@ -2710,8 +3886,28 @@ export default function TurmaDetalhePage() {
2710
3886
  key={aluno.id}
2711
3887
  className="hover:bg-muted/30"
2712
3888
  >
2713
- <TableCell className="sticky left-0 z-10 border-r border-border/40 bg-background text-sm font-medium">
2714
- {aluno.nome}
3889
+ <TableCell className="sticky left-0 z-10 min-w-[260px] border-r border-border/40 bg-background">
3890
+ <div className="flex min-w-0 items-center gap-3">
3891
+ <Avatar className="size-8 shrink-0">
3892
+ <AvatarImage
3893
+ src={getPersonAvatarUrl(aluno.avatarId)}
3894
+ alt={aluno.nome}
3895
+ />
3896
+ <AvatarFallback className="bg-gradient-to-br from-blue-100 to-blue-200 text-[11px] font-medium text-blue-700">
3897
+ {getPersonInitials(aluno.nome)}
3898
+ </AvatarFallback>
3899
+ </Avatar>
3900
+ <div className="min-w-0">
3901
+ <p className="truncate text-sm font-medium">
3902
+ {aluno.nome}
3903
+ </p>
3904
+ {aluno.email ? (
3905
+ <p className="truncate text-xs text-muted-foreground">
3906
+ {aluno.email}
3907
+ </p>
3908
+ ) : null}
3909
+ </div>
3910
+ </div>
2715
3911
  </TableCell>
2716
3912
  {aulasState.map((aula) => {
2717
3913
  const cellKey = `${aula.id}-${aluno.id}`;
@@ -2725,17 +3921,27 @@ export default function TurmaDetalhePage() {
2725
3921
  key={aula.id}
2726
3922
  className="text-center"
2727
3923
  >
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
- />
3924
+ <div className="flex items-center justify-center">
3925
+ {saving ? (
3926
+ <Loader2
3927
+ className="size-4 animate-spin text-muted-foreground"
3928
+ aria-label={t(
3929
+ 'toasts.attendanceSaving'
3930
+ )}
3931
+ />
3932
+ ) : (
3933
+ <Checkbox
3934
+ checked={present}
3935
+ onCheckedChange={() =>
3936
+ void toggleAttendanceCell(
3937
+ aula.id,
3938
+ aluno.id
3939
+ )
3940
+ }
3941
+ aria-label={`${aluno.nome} – ${format(parseSessionDate(aula.data), 'dd/MM/yyyy')}`}
3942
+ />
3943
+ )}
3944
+ </div>
2739
3945
  </TableCell>
2740
3946
  );
2741
3947
  })}
@@ -2746,14 +3952,301 @@ export default function TurmaDetalhePage() {
2746
3952
  </div>
2747
3953
  )}
2748
3954
  </TabsContent>
2749
- </Tabs>
2750
- </div>
2751
3955
 
2752
- {/* ── Right: Operational Sidebar ──────────────────────────────── */}
2753
- <div className="space-y-3 lg:sticky lg:top-4 lg:self-start">
2754
- {/* Next Session */}
2755
- <OperationalSidebarCard
2756
- title={t('sidebar.nextSession') ?? 'Próxima Aula'}
3956
+ {/* ── Tab Materiais Global ────────────────────────────────────────────── */}
3957
+ <TabsContent value="materiais-global" className="mt-0">
3958
+ {loadingGlobalMaterials ? (
3959
+ <div className="space-y-2">
3960
+ {Array.from({ length: 4 }).map((_, i) => (
3961
+ <div
3962
+ key={i}
3963
+ className="h-14 animate-pulse rounded-lg bg-muted"
3964
+ />
3965
+ ))}
3966
+ </div>
3967
+ ) : (
3968
+ <div className="flex flex-col gap-4">
3969
+ {/* Materials list */}
3970
+ {globalMaterials.length > 0 ? (
3971
+ <div className="space-y-2">
3972
+ {globalMaterials.map((mat) => (
3973
+ <div
3974
+ key={mat.id}
3975
+ className="flex items-center gap-3 rounded-lg border border-border/60 p-3 hover:bg-muted/20"
3976
+ >
3977
+ <MaterialIconCell material={mat} />
3978
+ <div className="min-w-0 flex-1">
3979
+ <p className="truncate text-sm font-medium">
3980
+ {mat.title}
3981
+ </p>
3982
+ {mat.url && (
3983
+ <a
3984
+ href={mat.url}
3985
+ target="_blank"
3986
+ rel="noopener noreferrer"
3987
+ className="truncate text-xs text-blue-600 hover:underline"
3988
+ onClick={(e) => e.stopPropagation()}
3989
+ >
3990
+ {mat.url}
3991
+ </a>
3992
+ )}
3993
+ {mat.file?.filename && (
3994
+ <p className="truncate text-xs text-muted-foreground">
3995
+ {mat.file.filename}
3996
+ </p>
3997
+ )}
3998
+ {mat.course_class_session && (
3999
+ <p className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-muted-foreground/70">
4000
+ <CalendarIcon className="size-3 shrink-0" />
4001
+ {mat.course_class_session.title}
4002
+ </p>
4003
+ )}
4004
+ </div>
4005
+ <Button
4006
+ type="button"
4007
+ variant="ghost"
4008
+ size="icon"
4009
+ className="size-7 shrink-0 text-destructive hover:text-destructive"
4010
+ onClick={() =>
4011
+ handleDeleteGlobalMaterial(mat.id)
4012
+ }
4013
+ >
4014
+ <Trash2 className="size-4" />
4015
+ </Button>
4016
+ </div>
4017
+ ))}
4018
+ </div>
4019
+ ) : (
4020
+ <div className="rounded-xl border border-dashed border-border/60 bg-muted/20 px-6 py-10 text-center">
4021
+ <div className="flex flex-col items-center gap-2">
4022
+ <Paperclip className="size-8 text-muted-foreground/50" />
4023
+ <p className="text-sm text-muted-foreground">
4024
+ Nenhum material cadastrado ainda.
4025
+ </p>
4026
+ </div>
4027
+ </div>
4028
+ )}
4029
+
4030
+ {/* Upload queue */}
4031
+ {globalUploadQueue.length > 0 && (
4032
+ <div className="space-y-2">
4033
+ {globalUploadQueue.map((item) => (
4034
+ <div
4035
+ key={item.id}
4036
+ className="rounded-lg border border-border/60 bg-muted/20 p-3"
4037
+ >
4038
+ <div className="mb-1.5 flex items-center gap-2">
4039
+ {item.status === 'done' ? (
4040
+ <CheckCircle2 className="size-4 shrink-0 text-green-500" />
4041
+ ) : item.status === 'error' ? (
4042
+ <AlertTriangle className="size-4 shrink-0 text-destructive" />
4043
+ ) : (
4044
+ <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
4045
+ )}
4046
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">
4047
+ {item.file.name}
4048
+ </span>
4049
+ <span className="shrink-0 text-xs text-muted-foreground">
4050
+ {item.status === 'error'
4051
+ ? 'Erro'
4052
+ : item.status === 'done'
4053
+ ? 'Concluído'
4054
+ : `${item.progress}%`}
4055
+ </span>
4056
+ {item.status === 'error' && (
4057
+ <Button
4058
+ type="button"
4059
+ variant="ghost"
4060
+ size="icon"
4061
+ className="size-6 shrink-0"
4062
+ onClick={() =>
4063
+ setGlobalUploadQueue((prev) =>
4064
+ prev.filter((q) => q.id !== item.id)
4065
+ )
4066
+ }
4067
+ >
4068
+ <X className="size-3.5" />
4069
+ </Button>
4070
+ )}
4071
+ </div>
4072
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
4073
+ <div
4074
+ className={cn(
4075
+ 'h-full rounded-full transition-all duration-300',
4076
+ item.status === 'error'
4077
+ ? 'bg-destructive'
4078
+ : item.status === 'done'
4079
+ ? 'bg-green-500'
4080
+ : 'bg-primary'
4081
+ )}
4082
+ style={{ width: `${item.progress}%` }}
4083
+ />
4084
+ </div>
4085
+ </div>
4086
+ ))}
4087
+ </div>
4088
+ )}
4089
+
4090
+ {/* Drop zone */}
4091
+ <div
4092
+ className={cn(
4093
+ 'relative rounded-xl border-2 border-dashed transition-colors',
4094
+ globalIsDragOver
4095
+ ? 'border-primary bg-primary/5'
4096
+ : 'border-border/60 hover:border-primary/40 hover:bg-muted/20'
4097
+ )}
4098
+ onDragOver={(e) => {
4099
+ e.preventDefault();
4100
+ setGlobalIsDragOver(true);
4101
+ }}
4102
+ onDragLeave={(e) => {
4103
+ if (
4104
+ !e.currentTarget.contains(e.relatedTarget as Node)
4105
+ )
4106
+ setGlobalIsDragOver(false);
4107
+ }}
4108
+ onDrop={(e) => {
4109
+ e.preventDefault();
4110
+ setGlobalIsDragOver(false);
4111
+ const files = Array.from(e.dataTransfer.files);
4112
+ if (files.length) void uploadGlobalFiles(files);
4113
+ }}
4114
+ >
4115
+ <input
4116
+ ref={globalMaterialFileInputRef}
4117
+ type="file"
4118
+ multiple
4119
+ className="hidden"
4120
+ onChange={(e) => {
4121
+ const files = Array.from(e.target.files ?? []);
4122
+ if (files.length) void uploadGlobalFiles(files);
4123
+ if (globalMaterialFileInputRef.current)
4124
+ globalMaterialFileInputRef.current.value = '';
4125
+ }}
4126
+ />
4127
+ <button
4128
+ type="button"
4129
+ className="w-full p-8 text-center"
4130
+ onClick={() =>
4131
+ globalMaterialFileInputRef.current?.click()
4132
+ }
4133
+ >
4134
+ <div className="flex flex-col items-center gap-2">
4135
+ <Upload
4136
+ className={cn(
4137
+ 'size-8 transition-colors',
4138
+ globalIsDragOver
4139
+ ? 'text-primary'
4140
+ : 'text-muted-foreground/50'
4141
+ )}
4142
+ />
4143
+ <div>
4144
+ <p className="text-sm font-medium">
4145
+ {globalIsDragOver
4146
+ ? 'Solte os arquivos aqui'
4147
+ : 'Arraste arquivos ou clique para selecionar'}
4148
+ </p>
4149
+ <p className="mt-0.5 text-xs text-muted-foreground">
4150
+ Múltiplos arquivos suportados
4151
+ </p>
4152
+ </div>
4153
+ </div>
4154
+ </button>
4155
+ </div>
4156
+
4157
+ {/* Link form */}
4158
+ {globalAddLinkOpen ? (
4159
+ <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
4160
+ <Field>
4161
+ <FieldLabel>URL</FieldLabel>
4162
+ <div className="relative flex items-center">
4163
+ {globalLinkFavicon && (
4164
+ <img
4165
+ src={globalLinkFavicon}
4166
+ alt=""
4167
+ className="pointer-events-none absolute left-2.5 size-4 rounded-sm"
4168
+ onError={() => setGlobalLinkFavicon(null)}
4169
+ />
4170
+ )}
4171
+ <Input
4172
+ value={globalLinkUrl}
4173
+ onChange={(e) =>
4174
+ handleGlobalLinkUrlChange(e.target.value)
4175
+ }
4176
+ placeholder="https://..."
4177
+ autoFocus
4178
+ className={
4179
+ globalLinkFavicon ? 'pl-8' : undefined
4180
+ }
4181
+ />
4182
+ </div>
4183
+ </Field>
4184
+ <Field>
4185
+ <FieldLabel className="flex items-center gap-1.5">
4186
+ Título
4187
+ {globalFetchingLinkMeta && (
4188
+ <Loader2 className="size-3 animate-spin text-muted-foreground" />
4189
+ )}
4190
+ </FieldLabel>
4191
+ <Input
4192
+ value={globalLinkTitle}
4193
+ onChange={(e) => {
4194
+ setGlobalLinkTitle(e.target.value);
4195
+ globalLinkTitleEditedRef.current =
4196
+ e.target.value.length > 0;
4197
+ }}
4198
+ placeholder="Ex: Referência de estudo"
4199
+ />
4200
+ </Field>
4201
+ <div className="flex gap-2">
4202
+ <Button
4203
+ type="button"
4204
+ size="sm"
4205
+ onClick={handleGlobalAddLink}
4206
+ disabled={
4207
+ globalSavingLink || !globalLinkUrl.trim()
4208
+ }
4209
+ className="gap-1.5"
4210
+ >
4211
+ {globalSavingLink ? (
4212
+ <Loader2 className="size-3.5 animate-spin" />
4213
+ ) : (
4214
+ <Plus className="size-3.5" />
4215
+ )}
4216
+ Salvar
4217
+ </Button>
4218
+ <Button
4219
+ type="button"
4220
+ size="sm"
4221
+ variant="outline"
4222
+ onClick={resetGlobalLinkForm}
4223
+ >
4224
+ Cancelar
4225
+ </Button>
4226
+ </div>
4227
+ </div>
4228
+ ) : (
4229
+ <Button
4230
+ type="button"
4231
+ variant="outline"
4232
+ className="w-full gap-2"
4233
+ onClick={() => setGlobalAddLinkOpen(true)}
4234
+ >
4235
+ <Link className="size-4" />
4236
+ Adicionar link
4237
+ </Button>
4238
+ )}
4239
+ </div>
4240
+ )}
4241
+ </TabsContent>
4242
+ </Tabs>
4243
+ </div>
4244
+
4245
+ {/* ── Right: Operational Sidebar ──────────────────────────────── */}
4246
+ <div className="space-y-3 lg:sticky lg:top-4 lg:self-start">
4247
+ {/* Next Session */}
4248
+ <OperationalSidebarCard
4249
+ title={t('sidebar.nextSession') ?? 'Próxima Aula'}
2757
4250
  >
2758
4251
  {loading ? (
2759
4252
  <div className="space-y-2">
@@ -2781,7 +4274,7 @@ export default function TurmaDetalhePage() {
2781
4274
  </div>
2782
4275
  {nextSession.tipo === 'online' &&
2783
4276
  (nextSession.meetingUrl || nextSession.local) ? (
2784
- <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
4277
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
2785
4278
  <Video className="size-3.5 shrink-0" />
2786
4279
  <a
2787
4280
  href={nextSession.meetingUrl || nextSession.local}
@@ -2791,6 +4284,12 @@ export default function TurmaDetalhePage() {
2791
4284
  >
2792
4285
  {t('info.accessRoom')}
2793
4286
  </a>
4287
+ <CopyButton
4288
+ value={
4289
+ nextSession.meetingUrl || nextSession.local || ''
4290
+ }
4291
+ className="size-5 shrink-0"
4292
+ />
2794
4293
  </div>
2795
4294
  ) : nextSession.local ? (
2796
4295
  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -2801,6 +4300,12 @@ export default function TurmaDetalhePage() {
2801
4300
  {nextSession.instructorName && (
2802
4301
  <div className="flex items-center gap-2 pt-1">
2803
4302
  <Avatar className="size-6">
4303
+ <AvatarImage
4304
+ src={getPersonAvatarUrl(
4305
+ nextSession.instructorAvatarId
4306
+ )}
4307
+ alt={nextSession.instructorName}
4308
+ />
2804
4309
  <AvatarFallback className="text-[9px]">
2805
4310
  {getPersonInitials(nextSession.instructorName)}
2806
4311
  </AvatarFallback>
@@ -2850,6 +4355,10 @@ export default function TurmaDetalhePage() {
2850
4355
  ) : primaryInstructor !== 'Nao definido' ? (
2851
4356
  <div className="flex items-center gap-2.5">
2852
4357
  <Avatar className="size-9">
4358
+ <AvatarImage
4359
+ src={getPersonAvatarUrl(primaryInstructorAvatarId)}
4360
+ alt={primaryInstructor}
4361
+ />
2853
4362
  <AvatarFallback className="bg-primary/10 text-xs font-medium text-primary">
2854
4363
  {getPersonInitials(primaryInstructor)}
2855
4364
  </AvatarFallback>
@@ -2884,7 +4393,7 @@ export default function TurmaDetalhePage() {
2884
4393
  <div className="flex items-center justify-between gap-2">
2885
4394
  <div className="flex min-w-0 items-center gap-2">
2886
4395
  <CourseAvatar
2887
- fileId={courseDetail?.logoFileId}
4396
+ fileId={courseLogoFileId}
2888
4397
  title={classTitle}
2889
4398
  className="size-8 shrink-0 rounded-lg"
2890
4399
  iconSize="size-4"
@@ -2984,20 +4493,10 @@ export default function TurmaDetalhePage() {
2984
4493
  >
2985
4494
  {t('info.accessRoom')}
2986
4495
  </a>
2987
- <Button
2988
- variant="ghost"
2989
- size="icon"
4496
+ <CopyButton
4497
+ value={roomUrl}
2990
4498
  className="size-5 shrink-0"
2991
- onClick={() => {
2992
- void navigator.clipboard.writeText(roomUrl);
2993
- toast.success(
2994
- t('sidebar.linkCopied') ?? 'Link copiado!'
2995
- );
2996
- }}
2997
- aria-label="Copiar link"
2998
- >
2999
- <Save className="size-3" />
3000
- </Button>
4499
+ />
3001
4500
  </div>
3002
4501
  ) : (
3003
4502
  <p className="font-medium">{location}</p>
@@ -3068,7 +4567,7 @@ export default function TurmaDetalhePage() {
3068
4567
  size="sm"
3069
4568
  variant="outline"
3070
4569
  className="mt-2 w-full gap-2 text-xs"
3071
- onClick={() => setActiveTab('alunos')}
4570
+ onClick={handleViewStudents}
3072
4571
  >
3073
4572
  <Users className="size-3.5" />
3074
4573
  {t('sidebar.viewStudents') ?? 'Ver alunos'}
@@ -3110,6 +4609,25 @@ export default function TurmaDetalhePage() {
3110
4609
  </div>
3111
4610
  ) : selectedStudentProfile ? (
3112
4611
  <div className="space-y-3">
4612
+ <div className="flex items-center gap-3 rounded-lg border p-3">
4613
+ <Avatar className="size-12">
4614
+ <AvatarImage
4615
+ src={getPersonAvatarUrl(selectedStudentProfile.avatarId)}
4616
+ alt={selectedStudentProfile.nome}
4617
+ />
4618
+ <AvatarFallback className="bg-gradient-to-br from-blue-100 to-blue-200 text-xs font-medium text-blue-700">
4619
+ {getPersonInitials(selectedStudentProfile.nome)}
4620
+ </AvatarFallback>
4621
+ </Avatar>
4622
+ <div className="min-w-0">
4623
+ <p className="truncate font-medium">
4624
+ {selectedStudentProfile.nome}
4625
+ </p>
4626
+ <p className="truncate text-xs text-muted-foreground">
4627
+ {selectedStudentProfile.email || '—'}
4628
+ </p>
4629
+ </div>
4630
+ </div>
3113
4631
  <div className="rounded-lg border p-3">
3114
4632
  <p className="text-xs text-muted-foreground">
3115
4633
  {t('dialogs.studentProfile.fields.name')}
@@ -3167,119 +4685,38 @@ export default function TurmaDetalhePage() {
3167
4685
  >
3168
4686
  {t('common.cancel')}
3169
4687
  </Button>
3170
- <Button onClick={openEditStudentSheet} className="gap-2">
3171
- <Pencil className="size-4" />
4688
+ <Button
4689
+ onClick={() => void openEditStudentSheet()}
4690
+ disabled={loadingStudentPerson || !selectedStudentProfile}
4691
+ className="gap-2"
4692
+ >
4693
+ {loadingStudentPerson ? (
4694
+ <Loader2 className="size-4 animate-spin" />
4695
+ ) : (
4696
+ <Pencil className="size-4" />
4697
+ )}
3172
4698
  {t('common.edit')}
3173
4699
  </Button>
3174
4700
  </DialogFooter>
3175
4701
  </DialogContent>
3176
4702
  </Dialog>
3177
4703
 
3178
- <Sheet open={editStudentSheetOpen} onOpenChange={setEditStudentSheetOpen}>
3179
- <SheetContent
3180
- side="right"
3181
- className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
3182
- >
3183
- <SheetHeader>
3184
- <SheetTitle>{t('dialogs.editStudent.title')}</SheetTitle>
3185
- <SheetDescription>
3186
- {t('dialogs.editStudent.description')}
3187
- </SheetDescription>
3188
- </SheetHeader>
3189
-
3190
- <Form {...editStudentForm}>
3191
- <form
3192
- onSubmit={handleUpdateStudentProfile}
3193
- className="mt-6 space-y-3 px-4"
3194
- >
3195
- <FormField
3196
- control={editStudentForm.control}
3197
- name="name"
3198
- render={({ field }) => (
3199
- <FormItem>
3200
- <FormLabel>
3201
- {t('dialogs.editStudent.fields.name')}
3202
- </FormLabel>
3203
- <FormControl>
3204
- <Input
3205
- {...field}
3206
- placeholder={t(
3207
- 'dialogs.editStudent.fields.namePlaceholder'
3208
- )}
3209
- />
3210
- </FormControl>
3211
- <FormMessage />
3212
- </FormItem>
3213
- )}
3214
- />
3215
- <FormField
3216
- control={editStudentForm.control}
3217
- name="email"
3218
- render={({ field }) => (
3219
- <FormItem>
3220
- <FormLabel>
3221
- {t('dialogs.editStudent.fields.email')}
3222
- </FormLabel>
3223
- <FormControl>
3224
- <Input
3225
- type="email"
3226
- {...field}
3227
- placeholder={t(
3228
- 'dialogs.editStudent.fields.emailPlaceholder'
3229
- )}
3230
- />
3231
- </FormControl>
3232
- <FormMessage />
3233
- </FormItem>
3234
- )}
3235
- />
3236
- <FormField
3237
- control={editStudentForm.control}
3238
- name="phone"
3239
- render={({ field }) => (
3240
- <FormItem>
3241
- <FormLabel>
3242
- {t('dialogs.editStudent.fields.phone')}
3243
- </FormLabel>
3244
- <FormControl>
3245
- <Input
3246
- {...field}
3247
- placeholder={t(
3248
- 'dialogs.editStudent.fields.phonePlaceholder'
3249
- )}
3250
- />
3251
- </FormControl>
3252
- <FormMessage />
3253
- </FormItem>
3254
- )}
3255
- />
3256
-
3257
- <SheetFooter className="mt-6 px-0">
3258
- <Button
3259
- type="button"
3260
- variant="outline"
3261
- onClick={() => {
3262
- setEditStudentSheetOpen(false);
3263
- setStudentProfileDialogOpen(true);
3264
- }}
3265
- >
3266
- {t('common.cancel')}
3267
- </Button>
3268
- <Button
3269
- type="submit"
3270
- disabled={savingStudentProfile}
3271
- className="gap-2"
3272
- >
3273
- {savingStudentProfile && (
3274
- <Loader2 className="size-4 animate-spin" />
3275
- )}
3276
- {t('common.save')}
3277
- </Button>
3278
- </SheetFooter>
3279
- </form>
3280
- </Form>
3281
- </SheetContent>
3282
- </Sheet>
4704
+ <PersonFormSheet
4705
+ open={editStudentSheetOpen}
4706
+ person={selectedStudentPerson}
4707
+ contactTypes={contactTypes}
4708
+ documentTypes={documentTypes}
4709
+ onOpenChange={(open) => {
4710
+ setEditStudentSheetOpen(open);
4711
+ if (!open) {
4712
+ setSelectedStudentPerson(null);
4713
+ }
4714
+ }}
4715
+ onSuccess={handleStudentPersonSaved}
4716
+ title={t('dialogs.editStudent.title')}
4717
+ description={t('dialogs.editStudent.description')}
4718
+ allowedTypes={['individual']}
4719
+ />
3283
4720
 
3284
4721
  {/* ── Dialog Remover Aluno ─────────────────────────────────────────────── */}
3285
4722
  <Dialog
@@ -3321,6 +4758,53 @@ export default function TurmaDetalhePage() {
3321
4758
  </DialogContent>
3322
4759
  </Dialog>
3323
4760
 
4761
+ {/* ── Dialog Remover Aula ──────────────────────────────────────────────── */}
4762
+ <Dialog
4763
+ open={deleteAulaDialogOpen}
4764
+ onOpenChange={setDeleteAulaDialogOpen}
4765
+ >
4766
+ <DialogContent>
4767
+ <DialogHeader>
4768
+ <DialogTitle className="flex items-center gap-2">
4769
+ <AlertTriangle className="size-5 text-destructive" />
4770
+ Remover aula
4771
+ </DialogTitle>
4772
+ <DialogDescription>
4773
+ Tem certeza que deseja remover a aula{' '}
4774
+ <strong>{aulaToDelete?.titulo}</strong>?
4775
+ {aulaToDelete?.isRecurring && (
4776
+ <>
4777
+ {' '}
4778
+ Esta aula faz parte de uma série recorrente. Somente esta
4779
+ ocorrência será removida.
4780
+ </>
4781
+ )}{' '}
4782
+ Esta ação não pode ser desfeita.
4783
+ </DialogDescription>
4784
+ </DialogHeader>
4785
+ <DialogFooter>
4786
+ <Button
4787
+ variant="outline"
4788
+ onClick={() => {
4789
+ setDeleteAulaDialogOpen(false);
4790
+ setAulaToDelete(null);
4791
+ }}
4792
+ >
4793
+ {t('common.cancel')}
4794
+ </Button>
4795
+ <Button
4796
+ variant="destructive"
4797
+ onClick={() => void handleDeleteAula()}
4798
+ disabled={deletingAula}
4799
+ className="gap-2"
4800
+ >
4801
+ {deletingAula && <Loader2 className="size-4 animate-spin" />}
4802
+ {t('common.remove')}
4803
+ </Button>
4804
+ </DialogFooter>
4805
+ </DialogContent>
4806
+ </Dialog>
4807
+
3324
4808
  {/* ── Sheet Aula ───────────────────────────────────────────────────────── */}
3325
4809
  <Sheet open={aulaSheetOpen} onOpenChange={setAulaSheetOpen}>
3326
4810
  <SheetContent
@@ -3342,15 +4826,31 @@ export default function TurmaDetalhePage() {
3342
4826
  <Tabs
3343
4827
  value={aulaSheetTab}
3344
4828
  onValueChange={(value) =>
3345
- setAulaSheetTab(value as 'aulas' | 'chamada')
4829
+ setAulaSheetTab(value as 'aulas' | 'chamada' | 'materiais')
3346
4830
  }
3347
4831
  className="mt-6 px-4"
3348
4832
  >
3349
- <TabsList className="grid w-full grid-cols-2">
4833
+ <TabsList
4834
+ className={cn(
4835
+ 'grid w-full',
4836
+ editingAula ? 'grid-cols-3' : 'grid-cols-2'
4837
+ )}
4838
+ >
3350
4839
  <TabsTrigger value="aulas">{t('sheet.tabs.lessons')}</TabsTrigger>
3351
4840
  <TabsTrigger value="chamada">
3352
4841
  {t('sheet.tabs.attendance')}
3353
4842
  </TabsTrigger>
4843
+ {editingAula && (
4844
+ <TabsTrigger value="materiais" className="gap-1.5">
4845
+ <Paperclip className="size-3.5" />
4846
+ Materiais
4847
+ {materials.length > 0 && (
4848
+ <span className="ml-0.5 rounded-full bg-primary/10 px-1 text-[10px] font-semibold text-primary">
4849
+ {materials.length}
4850
+ </span>
4851
+ )}
4852
+ </TabsTrigger>
4853
+ )}
3354
4854
  </TabsList>
3355
4855
 
3356
4856
  <TabsContent value="aulas" className="mt-4">
@@ -3643,16 +5143,47 @@ export default function TurmaDetalhePage() {
3643
5143
  role="combobox"
3644
5144
  className="w-full justify-between"
3645
5145
  >
3646
- <span className="truncate text-left">
3647
- {instructorOptions.find(
5146
+ {(() => {
5147
+ const sel = instructorOptions.find(
3648
5148
  (instructor) =>
3649
5149
  String(instructor.id) === field.value
3650
- )?.name ||
3651
- editingAula?.instructorName ||
3652
- t(
3653
- 'sheet.lessonForm.fields.instructorPlaceholder'
3654
- )}
3655
- </span>
5150
+ );
5151
+ const name =
5152
+ sel?.name || editingAula?.instructorName;
5153
+ const avatarId =
5154
+ sel?.avatarId ??
5155
+ editingAula?.instructorAvatarId;
5156
+ if (name) {
5157
+ const initials = name
5158
+ .split(' ')
5159
+ .filter(Boolean)
5160
+ .slice(0, 2)
5161
+ .map((p) => p[0]?.toUpperCase() ?? '')
5162
+ .join('');
5163
+ return (
5164
+ <div className="flex min-w-0 items-center gap-2">
5165
+ <Avatar className="h-5 w-5 shrink-0 rounded">
5166
+ <AvatarImage
5167
+ src={getPersonAvatarUrl(avatarId)}
5168
+ />
5169
+ <AvatarFallback className="rounded bg-muted text-[10px] font-semibold">
5170
+ {initials}
5171
+ </AvatarFallback>
5172
+ </Avatar>
5173
+ <span className="truncate text-left">
5174
+ {name}
5175
+ </span>
5176
+ </div>
5177
+ );
5178
+ }
5179
+ return (
5180
+ <span className="truncate text-left text-muted-foreground">
5181
+ {t(
5182
+ 'sheet.lessonForm.fields.instructorPlaceholder'
5183
+ )}
5184
+ </span>
5185
+ );
5186
+ })()}
3656
5187
  {loadingInstructors ? (
3657
5188
  <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-60" />
3658
5189
  ) : (
@@ -3695,19 +5226,43 @@ export default function TurmaDetalhePage() {
3695
5226
  </div>
3696
5227
  </CommandEmpty>
3697
5228
  <CommandGroup>
3698
- {instructorOptions.map((instructor) => (
3699
- <CommandItem
3700
- key={instructor.id}
3701
- value={`${instructor.name}-${instructor.id}`}
3702
- onSelect={() => {
3703
- field.onChange(String(instructor.id));
3704
- setInstructorOpen(false);
3705
- setInstructorSearch('');
3706
- }}
3707
- >
3708
- {instructor.name}
3709
- </CommandItem>
3710
- ))}
5229
+ {instructorOptions.map((instructor) => {
5230
+ const initials = instructor.name
5231
+ .split(' ')
5232
+ .filter(Boolean)
5233
+ .slice(0, 2)
5234
+ .map((p) => p[0]?.toUpperCase() ?? '')
5235
+ .join('');
5236
+ return (
5237
+ <CommandItem
5238
+ key={instructor.id}
5239
+ value={`${instructor.name}-${instructor.id}`}
5240
+ onSelect={() => {
5241
+ field.onChange(
5242
+ String(instructor.id)
5243
+ );
5244
+ setInstructorOpen(false);
5245
+ setInstructorSearch('');
5246
+ }}
5247
+ >
5248
+ <div className="flex min-w-0 items-center gap-3 py-0.5">
5249
+ <Avatar className="h-7 w-7 shrink-0 rounded-lg border border-border/60">
5250
+ <AvatarImage
5251
+ src={getPersonAvatarUrl(
5252
+ instructor.avatarId
5253
+ )}
5254
+ />
5255
+ <AvatarFallback className="rounded-lg bg-muted text-[11px] font-semibold text-foreground">
5256
+ {initials}
5257
+ </AvatarFallback>
5258
+ </Avatar>
5259
+ <span className="truncate text-sm">
5260
+ {instructor.name}
5261
+ </span>
5262
+ </div>
5263
+ </CommandItem>
5264
+ );
5265
+ })}
3711
5266
  </CommandGroup>
3712
5267
  </CommandList>
3713
5268
  </Command>
@@ -3776,9 +5331,11 @@ export default function TurmaDetalhePage() {
3776
5331
  size="sm"
3777
5332
  onClick={() =>
3778
5333
  setPresencaList((prev) =>
3779
- prev.map((p) =>
3780
- p.selecionado ? { ...p, presente: true } : p
3781
- )
5334
+ prev.map((p) => ({
5335
+ ...p,
5336
+ selecionado: true,
5337
+ presente: true,
5338
+ }))
3782
5339
  )
3783
5340
  }
3784
5341
  >
@@ -3789,9 +5346,11 @@ export default function TurmaDetalhePage() {
3789
5346
  size="sm"
3790
5347
  onClick={() =>
3791
5348
  setPresencaList((prev) =>
3792
- prev.map((p) =>
3793
- p.selecionado ? { ...p, presente: false } : p
3794
- )
5349
+ prev.map((p) => ({
5350
+ ...p,
5351
+ selecionado: true,
5352
+ presente: false,
5353
+ }))
3795
5354
  )
3796
5355
  }
3797
5356
  >
@@ -3818,6 +5377,10 @@ export default function TurmaDetalhePage() {
3818
5377
  aria-label={t('sheet.attendance.participantLabel')}
3819
5378
  />
3820
5379
  <Avatar className="size-9">
5380
+ <AvatarImage
5381
+ src={getPersonAvatarUrl(aluno.avatarId)}
5382
+ alt={aluno.nome}
5383
+ />
3821
5384
  <AvatarFallback className="text-xs">
3822
5385
  {aluno.nome
3823
5386
  .split(' ')
@@ -3826,7 +5389,14 @@ export default function TurmaDetalhePage() {
3826
5389
  .slice(0, 2)}
3827
5390
  </AvatarFallback>
3828
5391
  </Avatar>
3829
- <span className="font-medium">{aluno.nome}</span>
5392
+ <div className="min-w-0">
5393
+ <p className="truncate font-medium">{aluno.nome}</p>
5394
+ {aluno.email ? (
5395
+ <p className="truncate text-xs text-muted-foreground">
5396
+ {aluno.email}
5397
+ </p>
5398
+ ) : null}
5399
+ </div>
3830
5400
  </div>
3831
5401
  <div className="flex items-center gap-3">
3832
5402
  <span
@@ -3864,6 +5434,269 @@ export default function TurmaDetalhePage() {
3864
5434
  </Button>
3865
5435
  </SheetFooter>
3866
5436
  </TabsContent>
5437
+
5438
+ {/* ── Tab Materiais ─────────────────────────────────────────── */}
5439
+ <TabsContent value="materiais" className="mt-4 space-y-3">
5440
+ {loadingMaterials ? (
5441
+ <div className="space-y-2">
5442
+ {Array.from({ length: 3 }).map((_, i) => (
5443
+ <div
5444
+ key={i}
5445
+ className="h-12 animate-pulse rounded-lg bg-muted"
5446
+ />
5447
+ ))}
5448
+ </div>
5449
+ ) : (
5450
+ <div className="flex flex-col gap-3">
5451
+ {/* Materials list */}
5452
+ {materials.length > 0 && (
5453
+ <div className="space-y-2">
5454
+ {materials.map((mat) => (
5455
+ <div
5456
+ key={mat.id}
5457
+ className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
5458
+ >
5459
+ <MaterialIconCell material={mat} />
5460
+ <div className="min-w-0 flex-1">
5461
+ <p className="truncate text-sm font-medium">
5462
+ {mat.title}
5463
+ </p>
5464
+ {mat.url && (
5465
+ <a
5466
+ href={mat.url}
5467
+ target="_blank"
5468
+ rel="noopener noreferrer"
5469
+ className="truncate text-xs text-blue-600 hover:underline"
5470
+ onClick={(e) => e.stopPropagation()}
5471
+ >
5472
+ {mat.url}
5473
+ </a>
5474
+ )}
5475
+ {mat.file?.filename && (
5476
+ <p className="truncate text-xs text-muted-foreground">
5477
+ {mat.file.filename}
5478
+ </p>
5479
+ )}
5480
+ </div>
5481
+ <Button
5482
+ type="button"
5483
+ variant="ghost"
5484
+ size="icon"
5485
+ className="size-7 shrink-0 text-destructive hover:text-destructive"
5486
+ onClick={() => handleDeleteMaterial(mat.id)}
5487
+ >
5488
+ <Trash2 className="size-4" />
5489
+ </Button>
5490
+ </div>
5491
+ ))}
5492
+ </div>
5493
+ )}
5494
+
5495
+ {/* Upload queue */}
5496
+ {uploadQueue.length > 0 && (
5497
+ <div className="space-y-2">
5498
+ {uploadQueue.map((item) => (
5499
+ <div
5500
+ key={item.id}
5501
+ className="rounded-lg border border-border/60 bg-muted/20 p-3"
5502
+ >
5503
+ <div className="mb-1.5 flex items-center gap-2">
5504
+ {item.status === 'done' ? (
5505
+ <CheckCircle2 className="size-4 shrink-0 text-green-500" />
5506
+ ) : item.status === 'error' ? (
5507
+ <AlertTriangle className="size-4 shrink-0 text-destructive" />
5508
+ ) : (
5509
+ <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
5510
+ )}
5511
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">
5512
+ {item.file.name}
5513
+ </span>
5514
+ <span className="shrink-0 text-xs text-muted-foreground">
5515
+ {item.status === 'error'
5516
+ ? 'Erro'
5517
+ : item.status === 'done'
5518
+ ? 'Concluído'
5519
+ : `${item.progress}%`}
5520
+ </span>
5521
+ {item.status === 'error' && (
5522
+ <Button
5523
+ type="button"
5524
+ variant="ghost"
5525
+ size="icon"
5526
+ className="size-6 shrink-0"
5527
+ onClick={() =>
5528
+ setUploadQueue((prev) =>
5529
+ prev.filter((q) => q.id !== item.id)
5530
+ )
5531
+ }
5532
+ >
5533
+ <X className="size-3.5" />
5534
+ </Button>
5535
+ )}
5536
+ </div>
5537
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
5538
+ <div
5539
+ className={cn(
5540
+ 'h-full rounded-full transition-all duration-300',
5541
+ item.status === 'error'
5542
+ ? 'bg-destructive'
5543
+ : item.status === 'done'
5544
+ ? 'bg-green-500'
5545
+ : 'bg-primary'
5546
+ )}
5547
+ style={{ width: `${item.progress}%` }}
5548
+ />
5549
+ </div>
5550
+ </div>
5551
+ ))}
5552
+ </div>
5553
+ )}
5554
+
5555
+ {/* Drop zone */}
5556
+ <div
5557
+ className={cn(
5558
+ 'relative rounded-xl border-2 border-dashed transition-colors',
5559
+ isDragOver
5560
+ ? 'border-primary bg-primary/5'
5561
+ : 'border-border/60 hover:border-primary/40 hover:bg-muted/20'
5562
+ )}
5563
+ onDragOver={(e) => {
5564
+ e.preventDefault();
5565
+ setIsDragOver(true);
5566
+ }}
5567
+ onDragLeave={(e) => {
5568
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
5569
+ setIsDragOver(false);
5570
+ }
5571
+ }}
5572
+ onDrop={(e) => {
5573
+ e.preventDefault();
5574
+ setIsDragOver(false);
5575
+ const files = Array.from(e.dataTransfer.files);
5576
+ if (files.length) void uploadFiles(files);
5577
+ }}
5578
+ >
5579
+ <input
5580
+ ref={materialFileInputRef}
5581
+ type="file"
5582
+ multiple
5583
+ className="hidden"
5584
+ onChange={(e) => {
5585
+ const files = Array.from(e.target.files ?? []);
5586
+ if (files.length) void uploadFiles(files);
5587
+ if (materialFileInputRef.current)
5588
+ materialFileInputRef.current.value = '';
5589
+ }}
5590
+ />
5591
+ <button
5592
+ type="button"
5593
+ className="w-full p-6 text-center"
5594
+ onClick={() => materialFileInputRef.current?.click()}
5595
+ >
5596
+ <div className="flex flex-col items-center gap-2">
5597
+ <Upload
5598
+ className={cn(
5599
+ 'size-8 transition-colors',
5600
+ isDragOver
5601
+ ? 'text-primary'
5602
+ : 'text-muted-foreground/50'
5603
+ )}
5604
+ />
5605
+ <div>
5606
+ <p className="text-sm font-medium">
5607
+ {isDragOver
5608
+ ? 'Solte os arquivos aqui'
5609
+ : 'Arraste arquivos ou clique para selecionar'}
5610
+ </p>
5611
+ <p className="mt-0.5 text-xs text-muted-foreground">
5612
+ Múltiplos arquivos suportados
5613
+ </p>
5614
+ </div>
5615
+ </div>
5616
+ </button>
5617
+ </div>
5618
+
5619
+ {/* Link form */}
5620
+ {addLinkOpen ? (
5621
+ <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
5622
+ <Field>
5623
+ <FieldLabel>URL</FieldLabel>
5624
+ <div className="relative flex items-center">
5625
+ {linkFavicon && (
5626
+ <img
5627
+ src={linkFavicon}
5628
+ alt=""
5629
+ className="pointer-events-none absolute left-2.5 size-4 rounded-sm"
5630
+ onError={() => setLinkFavicon(null)}
5631
+ />
5632
+ )}
5633
+ <Input
5634
+ value={newLinkUrl}
5635
+ onChange={(e) =>
5636
+ handleLinkUrlChange(e.target.value)
5637
+ }
5638
+ placeholder="https://..."
5639
+ autoFocus
5640
+ className={linkFavicon ? 'pl-8' : undefined}
5641
+ />
5642
+ </div>
5643
+ </Field>
5644
+ <Field>
5645
+ <FieldLabel className="flex items-center gap-1.5">
5646
+ Título
5647
+ {fetchingLinkMeta && (
5648
+ <Loader2 className="size-3 animate-spin text-muted-foreground" />
5649
+ )}
5650
+ </FieldLabel>
5651
+ <Input
5652
+ value={newLinkTitle}
5653
+ onChange={(e) => {
5654
+ setNewLinkTitle(e.target.value);
5655
+ linkTitleEditedRef.current =
5656
+ e.target.value.length > 0;
5657
+ }}
5658
+ placeholder="Ex: Slides da aula"
5659
+ />
5660
+ </Field>
5661
+ <div className="flex gap-2">
5662
+ <Button
5663
+ type="button"
5664
+ size="sm"
5665
+ onClick={handleAddLink}
5666
+ disabled={savingLink || !newLinkUrl.trim()}
5667
+ className="gap-1.5"
5668
+ >
5669
+ {savingLink ? (
5670
+ <Loader2 className="size-3.5 animate-spin" />
5671
+ ) : (
5672
+ <Plus className="size-3.5" />
5673
+ )}
5674
+ Salvar
5675
+ </Button>
5676
+ <Button
5677
+ type="button"
5678
+ size="sm"
5679
+ variant="outline"
5680
+ onClick={resetLinkForm}
5681
+ >
5682
+ Cancelar
5683
+ </Button>
5684
+ </div>
5685
+ </div>
5686
+ ) : (
5687
+ <Button
5688
+ type="button"
5689
+ variant="outline"
5690
+ className="w-full gap-2"
5691
+ onClick={() => setAddLinkOpen(true)}
5692
+ >
5693
+ <Link className="size-4" />
5694
+ Adicionar link
5695
+ </Button>
5696
+ )}
5697
+ </div>
5698
+ )}
5699
+ </TabsContent>
3867
5700
  </Tabs>
3868
5701
  </SheetContent>
3869
5702
  </Sheet>