@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.
- package/dist/class-group/class-group.controller.d.ts +64 -1
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.controller.js +35 -0
- package/dist/class-group/class-group.controller.js.map +1 -1
- package/dist/class-group/class-group.service.d.ts +66 -1
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/class-group/class-group.service.js +164 -13
- package/dist/class-group/class-group.service.js.map +1 -1
- package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -1
- package/dist/class-group/dto/create-class-group.dto.js +2 -1
- package/dist/class-group/dto/create-class-group.dto.js.map +1 -1
- package/dist/class-group/dto/material.dto.d.ts +18 -0
- package/dist/class-group/dto/material.dto.d.ts.map +1 -0
- package/dist/class-group/dto/material.dto.js +86 -0
- package/dist/class-group/dto/material.dto.js.map +1 -0
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +27 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +2 -2
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/enterprise/enterprise.controller.d.ts +7 -1
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +72 -2
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.module.d.ts.map +1 -1
- package/dist/enterprise/enterprise.module.js +2 -1
- package/dist/enterprise/enterprise.module.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +84 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/enterprise/training/enterprise-training.module.d.ts +3 -0
- package/dist/enterprise/training/enterprise-training.module.d.ts.map +1 -0
- package/dist/enterprise/training/enterprise-training.module.js +40 -0
- package/dist/enterprise/training/enterprise-training.module.js.map +1 -0
- package/dist/enterprise/training/training-admin.controller.d.ts +525 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-admin.controller.js +385 -0
- package/dist/enterprise/training/training-admin.controller.js.map +1 -0
- package/dist/enterprise/training/training-admin.service.d.ts +582 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-admin.service.js +2283 -0
- package/dist/enterprise/training/training-admin.service.js.map +1 -0
- package/dist/enterprise/training/training-instructor.controller.d.ts +260 -0
- package/dist/enterprise/training/training-instructor.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-instructor.controller.js +199 -0
- package/dist/enterprise/training/training-instructor.controller.js.map +1 -0
- package/dist/enterprise/training/training-instructor.service.d.ts +280 -0
- package/dist/enterprise/training/training-instructor.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-instructor.service.js +1218 -0
- package/dist/enterprise/training/training-instructor.service.js.map +1 -0
- package/dist/enterprise/training/training-student.controller.d.ts +168 -0
- package/dist/enterprise/training/training-student.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-student.controller.js +104 -0
- package/dist/enterprise/training/training-student.controller.js.map +1 -0
- package/dist/enterprise/training/training-student.service.d.ts +185 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-student.service.js +674 -0
- package/dist/enterprise/training/training-student.service.js.map +1 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts +298 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-viewer.controller.js +178 -0
- package/dist/enterprise/training/training-viewer.controller.js.map +1 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts +18 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts.map +1 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.js +59 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.js.map +1 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts +6 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts.map +1 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.js +9 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.js.map +1 -0
- package/dist/evaluation/evaluation.controller.d.ts +66 -0
- package/dist/evaluation/evaluation.controller.d.ts.map +1 -1
- package/dist/evaluation/evaluation.controller.js +73 -0
- package/dist/evaluation/evaluation.controller.js.map +1 -1
- package/dist/evaluation/evaluation.service.d.ts +71 -0
- package/dist/evaluation/evaluation.service.d.ts.map +1 -1
- package/dist/evaluation/evaluation.service.js +121 -0
- package/dist/evaluation/evaluation.service.js.map +1 -1
- package/dist/instructor/instructor.service.js +6 -6
- package/dist/instructor/instructor.service.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +3 -0
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/menu.yaml +19 -2
- package/hedhog/data/route.yaml +730 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +74 -8
- package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +10 -30
- package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +3 -3
- package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1 -1
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +2141 -308
- package/hedhog/frontend/app/classes/page.tsx.ejs +8 -7
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +14 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +6 -2
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +201 -0
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-types.ts.ejs +49 -0
- package/hedhog/frontend/app/evaluations/page.tsx.ejs +483 -1112
- package/hedhog/frontend/app/instructors/page.tsx.ejs +22 -20
- package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +1278 -0
- package/hedhog/frontend/messages/en.json +98 -7
- package/hedhog/frontend/messages/pt.json +98 -7
- package/hedhog/table/course_class_group_material.yaml +45 -0
- package/package.json +8 -8
- package/src/class-group/class-group.controller.ts +30 -0
- package/src/class-group/class-group.service.ts +176 -5
- package/src/class-group/dto/create-class-group.dto.ts +2 -2
- package/src/class-group/dto/material.dto.ts +69 -0
- package/src/course/course.service.ts +41 -8
- package/src/course/dto/create-course.dto.ts +2 -2
- package/src/enterprise/enterprise.controller.ts +62 -1
- package/src/enterprise/enterprise.module.ts +2 -1
- package/src/enterprise/enterprise.service.ts +84 -1
- package/src/enterprise/training/enterprise-training.module.ts +27 -0
- package/src/enterprise/training/training-admin.controller.ts +278 -0
- package/src/enterprise/training/training-admin.service.ts +2523 -0
- package/src/enterprise/training/training-instructor.controller.ts +141 -0
- package/src/enterprise/training/training-instructor.service.ts +1303 -0
- package/src/enterprise/training/training-student.controller.ts +65 -0
- package/src/enterprise/training/training-student.service.ts +762 -0
- package/src/enterprise/training/training-viewer.controller.ts +115 -0
- package/src/evaluation/dto/create-evaluation-topic.dto.ts +48 -0
- package/src/evaluation/dto/update-evaluation-topic.dto.ts +6 -0
- package/src/evaluation/evaluation.controller.ts +63 -1
- package/src/evaluation/evaluation.service.ts +150 -1
- package/src/instructor/instructor.service.ts +4 -4
- 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('
|
|
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 [
|
|
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<
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
1286
|
-
|
|
1287
|
-
if (!selectedStudentProfile) return;
|
|
1826
|
+
const handleStudentPersonSaved = async (person?: ContactPerson) => {
|
|
1827
|
+
await refetchAlunos();
|
|
1288
1828
|
|
|
1289
|
-
|
|
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/${
|
|
1293
|
-
method: '
|
|
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
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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={
|
|
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-
|
|
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-[
|
|
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
|
|
2714
|
-
|
|
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
|
-
<
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
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
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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
|
|
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={
|
|
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
|
-
<
|
|
2988
|
-
|
|
2989
|
-
size="icon"
|
|
4496
|
+
<CopyButton
|
|
4497
|
+
value={roomUrl}
|
|
2990
4498
|
className="size-5 shrink-0"
|
|
2991
|
-
|
|
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={
|
|
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
|
|
3171
|
-
|
|
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
|
-
<
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
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
|
|
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
|
-
|
|
3647
|
-
|
|
5146
|
+
{(() => {
|
|
5147
|
+
const sel = instructorOptions.find(
|
|
3648
5148
|
(instructor) =>
|
|
3649
5149
|
String(instructor.id) === field.value
|
|
3650
|
-
)
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
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
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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>
|