@hed-hog/lms 0.0.328 → 0.0.330

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/instructor/instructor.service.d.ts.map +1 -1
  2. package/dist/instructor/instructor.service.js +22 -16
  3. package/dist/instructor/instructor.service.js.map +1 -1
  4. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +18 -8
  5. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +7 -5
  6. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +5 -9
  7. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +5 -9
  8. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +15 -14
  9. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +66 -29
  10. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +4 -2
  11. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +44 -34
  12. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +10 -10
  13. package/hedhog/frontend/app/classes/page.tsx.ejs +23 -15
  14. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +5 -3
  15. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +5 -3
  16. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +9 -7
  17. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +3 -1
  18. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +4 -2
  19. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +24 -23
  20. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +21 -19
  21. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +7 -5
  22. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +18 -16
  23. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +13 -11
  24. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +5 -3
  25. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +14 -9
  26. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +42 -25
  27. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +3 -1
  28. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +10 -8
  29. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +22 -20
  30. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +3 -3
  31. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +21 -19
  32. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +34 -36
  33. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +3 -1
  34. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +7 -5
  35. package/hedhog/frontend/app/enterprise/page.tsx.ejs +106 -54
  36. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +79 -59
  37. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +92 -26
  38. package/hedhog/frontend/app/instructors/page.tsx.ejs +4 -2
  39. package/hedhog/frontend/messages/en.json +619 -13
  40. package/hedhog/frontend/messages/pt.json +619 -13
  41. package/package.json +7 -7
  42. package/src/instructor/instructor.service.ts +22 -19
  43. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +0 -591
  44. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +0 -109
  45. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +0 -60
  46. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +0 -134
  47. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +0 -113
  48. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +0 -314
  49. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +0 -174
  50. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +0 -185
  51. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +0 -277
  52. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +0 -207
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/lms",
3
- "version": "0.0.328",
3
+ "version": "0.0.330",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -10,14 +10,14 @@
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api": "0.0.8",
13
+ "@hed-hog/category": "0.0.330",
14
+ "@hed-hog/api-types": "0.0.1",
15
+ "@hed-hog/finance": "0.0.330",
16
+ "@hed-hog/core": "0.0.330",
13
17
  "@hed-hog/api-locale": "0.0.14",
14
- "@hed-hog/api-pagination": "0.0.7",
15
- "@hed-hog/contact": "0.0.328",
16
18
  "@hed-hog/api-prisma": "0.0.6",
17
- "@hed-hog/api-types": "0.0.1",
18
- "@hed-hog/category": "0.0.328",
19
- "@hed-hog/finance": "0.0.328",
20
- "@hed-hog/core": "0.0.328"
19
+ "@hed-hog/contact": "0.0.330",
20
+ "@hed-hog/api-pagination": "0.0.7"
21
21
  },
22
22
  "exports": {
23
23
  ".": {
@@ -160,7 +160,12 @@ export class InstructorService {
160
160
  hourlyRateMap.set(Number(r.id), r.hourly_rate !== null ? Number(r.hourly_rate) : null);
161
161
  }
162
162
 
163
+ const seenSkillSlugs = new Map<number, Set<string>>();
163
164
  for (const a of assignmentRows) {
165
+ const seen = seenSkillSlugs.get(a.instructor_id) ?? new Set<string>();
166
+ if (seen.has(a.instructor_skill.slug)) continue;
167
+ seen.add(a.instructor_skill.slug);
168
+ seenSkillSlugs.set(a.instructor_id, seen);
164
169
  const existing = skillsMap.get(a.instructor_id) ?? [];
165
170
  existing.push({
166
171
  id: a.instructor_skill.id,
@@ -480,11 +485,18 @@ export class InstructorService {
480
485
  ? Number(rateRows[0].hourly_rate)
481
486
  : null;
482
487
 
483
- const skills = skillAssignments.map((a) => ({
484
- id: a.instructor_skill.id,
485
- slug: a.instructor_skill.slug,
486
- name: a.instructor_skill.instructor_skill_locale?.[0]?.name ?? a.instructor_skill.slug,
487
- }));
488
+ const seenSlugs = new Set<string>();
489
+ const skills = skillAssignments
490
+ .filter((a) => {
491
+ if (seenSlugs.has(a.instructor_skill.slug)) return false;
492
+ seenSlugs.add(a.instructor_skill.slug);
493
+ return true;
494
+ })
495
+ .map((a) => ({
496
+ id: a.instructor_skill.id,
497
+ slug: a.instructor_skill.slug,
498
+ name: a.instructor_skill.instructor_skill_locale?.[0]?.name ?? a.instructor_skill.slug,
499
+ }));
488
500
 
489
501
  return {
490
502
  id: instructor.id,
@@ -890,12 +902,11 @@ export class InstructorService {
890
902
  ) {
891
903
  const normalizedSlugs = [...new Set((slugs ?? []).map((s) => s?.trim()).filter(Boolean))];
892
904
 
893
- if (normalizedSlugs.length === 0) {
894
- await (db as any).instructor_skill_assignment.deleteMany({
895
- where: { instructor_id: instructorId },
896
- });
897
- return;
898
- }
905
+ await (db as any).instructor_skill_assignment.deleteMany({
906
+ where: { instructor_id: instructorId },
907
+ });
908
+
909
+ if (normalizedSlugs.length === 0) return;
899
910
 
900
911
  const skillRows = await (db as any).instructor_skill.findMany({
901
912
  where: { slug: { in: normalizedSlugs } },
@@ -904,20 +915,12 @@ export class InstructorService {
904
915
 
905
916
  const skillIds = (skillRows as any[]).map((r) => r.id);
906
917
 
907
- await (db as any).instructor_skill_assignment.deleteMany({
908
- where: {
909
- instructor_id: instructorId,
910
- skill_id: { notIn: skillIds },
911
- },
912
- });
913
-
914
918
  if (skillIds.length > 0) {
915
919
  await (db as any).instructor_skill_assignment.createMany({
916
920
  data: skillIds.map((skillId: number) => ({
917
921
  instructor_id: instructorId,
918
922
  skill_id: skillId,
919
923
  })),
920
- skipDuplicates: true,
921
924
  });
922
925
  }
923
926
  }
@@ -1,591 +0,0 @@
1
- 'use client';
2
-
3
- import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
- import { Button } from '@/components/ui/button';
5
- import {
6
- Field,
7
- FieldDescription,
8
- FieldError,
9
- FieldLabel,
10
- } from '@/components/ui/field';
11
- import { Input } from '@/components/ui/input';
12
- import { Progress } from '@/components/ui/progress';
13
- import {
14
- Sheet,
15
- SheetContent,
16
- SheetDescription,
17
- SheetFooter,
18
- SheetHeader,
19
- SheetTitle,
20
- } from '@/components/ui/sheet';
21
- import { Switch } from '@/components/ui/switch';
22
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
- import { zodResolver } from '@hookform/resolvers/zod';
24
- import { Loader2, Trash2, Upload } from 'lucide-react';
25
- import { useEffect, useMemo, useRef, useState } from 'react';
26
- import { Controller, useForm } from 'react-hook-form';
27
- import { toast } from 'sonner';
28
- import { z } from 'zod';
29
-
30
- const createInstructorSchema = z.object({
31
- nome: z.string().trim().min(3, 'Nome e obrigatorio'),
32
- email: z
33
- .string()
34
- .trim()
35
- .max(255)
36
- .refine(
37
- (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
38
- 'E-mail invalido'
39
- ),
40
- telefone: z
41
- .string()
42
- .trim()
43
- .max(50)
44
- .refine(
45
- (value) => !value || /^[0-9+()\s-]{8,20}$/.test(value),
46
- 'Telefone invalido'
47
- ),
48
- qualificationSlugs: z
49
- .array(z.string())
50
- .min(1, 'Selecione pelo menos uma atuacao'),
51
- });
52
-
53
- type CreateInstructorForm = z.infer<typeof createInstructorSchema>;
54
-
55
- type CreatedInstructor = {
56
- id: number;
57
- personId: number;
58
- name: string;
59
- avatarId?: number | null;
60
- email?: string | null;
61
- phone?: string | null;
62
- qualificationSlugs: string[];
63
- };
64
-
65
- type InstructorDetails = CreatedInstructor & {
66
- status?: 'active' | 'inactive';
67
- };
68
-
69
- type CreateLmsInstructorSheetProps = {
70
- open: boolean;
71
- onOpenChange: (open: boolean) => void;
72
- onCreated?: (instructor: CreatedInstructor) => void | Promise<void>;
73
- onSaved?: (instructor: CreatedInstructor) => void | Promise<void>;
74
- instructorId?: number | null;
75
- title: string;
76
- description: string;
77
- submitLabel: string;
78
- successMessage: string;
79
- errorMessage: string;
80
- defaultQualificationSlugs?: string[];
81
- };
82
-
83
- type UploadedFilePayload = {
84
- id?: number;
85
- };
86
-
87
- type OpenFilePayload = {
88
- url?: string;
89
- };
90
-
91
- const QUALIFICATION_OPTIONS = [
92
- {
93
- slug: 'course-lessons',
94
- label: 'Aulas de curso',
95
- description: 'Pode atuar em aulas gravadas e estrutura de curso.',
96
- },
97
- {
98
- slug: 'class-sessions',
99
- label: 'Sessoes de turma',
100
- description: 'Pode atuar em contextos ao vivo e turmas.',
101
- },
102
- ];
103
-
104
- function getInstructorInitials(name?: string | null) {
105
- return String(name || '')
106
- .split(' ')
107
- .filter(Boolean)
108
- .slice(0, 2)
109
- .map((part) => part[0]?.toUpperCase() || '')
110
- .join('');
111
- }
112
-
113
- function getInstructorAvatarUrl(fileId?: number | null) {
114
- return typeof fileId === 'number' && fileId > 0
115
- ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${fileId}`
116
- : '/placeholder.png';
117
- }
118
-
119
- export function CreateLmsInstructorSheet({
120
- open,
121
- onOpenChange,
122
- onCreated,
123
- onSaved,
124
- instructorId,
125
- title,
126
- description,
127
- submitLabel,
128
- successMessage,
129
- errorMessage,
130
- defaultQualificationSlugs,
131
- }: CreateLmsInstructorSheetProps) {
132
- const { request } = useApp();
133
- const [saving, setSaving] = useState(false);
134
- const [avatarId, setAvatarId] = useState<number | null>(null);
135
- const [persistedAvatarId, setPersistedAvatarId] = useState<number | null>(
136
- null
137
- );
138
- const [avatarPreviewUrl, setAvatarPreviewUrl] =
139
- useState<string>('/placeholder.png');
140
- const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
141
- const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
142
- const fileInputRef = useRef<HTMLInputElement>(null);
143
- const isEditing = typeof instructorId === 'number' && instructorId > 0;
144
-
145
- const initialQualificationSlugs = useMemo(
146
- () =>
147
- defaultQualificationSlugs && defaultQualificationSlugs.length > 0
148
- ? [...new Set(defaultQualificationSlugs)]
149
- : ['course-lessons'],
150
- [defaultQualificationSlugs]
151
- );
152
-
153
- const form = useForm<CreateInstructorForm>({
154
- resolver: zodResolver(createInstructorSchema),
155
- defaultValues: {
156
- nome: '',
157
- email: '',
158
- telefone: '',
159
- qualificationSlugs: initialQualificationSlugs,
160
- },
161
- });
162
-
163
- const {
164
- data: instructorDetails,
165
- isLoading: loadingInstructorDetails,
166
- refetch: refetchInstructorDetails,
167
- } = useQuery<InstructorDetails>({
168
- queryKey: ['lms-instructor-sheet', instructorId],
169
- enabled: open && isEditing,
170
- queryFn: async () => {
171
- const response = await request<InstructorDetails>({
172
- url: `/lms/instructors/${instructorId}`,
173
- method: 'GET',
174
- });
175
-
176
- return response.data;
177
- },
178
- });
179
-
180
- useEffect(() => {
181
- if (!open || !isEditing) {
182
- return;
183
- }
184
-
185
- void refetchInstructorDetails();
186
- }, [isEditing, open, refetchInstructorDetails]);
187
-
188
- useEffect(() => {
189
- if (!open) {
190
- return;
191
- }
192
-
193
- const instructor = isEditing ? instructorDetails : undefined;
194
-
195
- if (isEditing && !instructor) {
196
- return;
197
- }
198
-
199
- const instructorQualificationSlugs: string[] = Array.isArray(
200
- instructor?.qualificationSlugs
201
- )
202
- ? instructor.qualificationSlugs
203
- : [];
204
-
205
- form.reset({
206
- nome: instructor?.name || '',
207
- email: instructor?.email || '',
208
- telefone: instructor?.phone || '',
209
- qualificationSlugs:
210
- instructorQualificationSlugs.length > 0
211
- ? instructorQualificationSlugs
212
- : initialQualificationSlugs,
213
- });
214
-
215
- const nextAvatarId = instructor?.avatarId ?? null;
216
- setAvatarId(nextAvatarId);
217
- setPersistedAvatarId(nextAvatarId);
218
- setAvatarPreviewUrl(getInstructorAvatarUrl(nextAvatarId));
219
- setIsUploadingAvatar(false);
220
- setAvatarUploadProgress(0);
221
- }, [form, initialQualificationSlugs, instructorDetails, isEditing, open]);
222
-
223
- const deleteFileById = async (fileId?: number | null) => {
224
- if (!fileId || fileId <= 0) return;
225
-
226
- try {
227
- await request({
228
- url: '/file',
229
- method: 'DELETE',
230
- data: { ids: [fileId] },
231
- });
232
- } catch {
233
- // Ignore cleanup failures to avoid interrupting the sheet flow.
234
- }
235
- };
236
-
237
- const cleanupUnsavedAvatar = async () => {
238
- if (avatarId && avatarId !== persistedAvatarId) {
239
- await deleteFileById(avatarId);
240
- }
241
- };
242
-
243
- const handleAvatarUpload = async (file: File) => {
244
- if (!file.type.startsWith('image/')) {
245
- toast.error('Selecione um arquivo de imagem valido.');
246
- return;
247
- }
248
-
249
- if (file.size > 5 * 1024 * 1024) {
250
- toast.error('A imagem deve ter no maximo 5MB.');
251
- return;
252
- }
253
-
254
- setIsUploadingAvatar(true);
255
- setAvatarUploadProgress(0);
256
-
257
- try {
258
- const formData = new FormData();
259
- formData.append('file', file);
260
- formData.append('destination', 'contact/person/avatar');
261
-
262
- const previousAvatarId = avatarId;
263
-
264
- const { data } = await request<UploadedFilePayload>({
265
- url: '/file',
266
- method: 'POST',
267
- data: formData,
268
- headers: {
269
- 'Content-Type': 'multipart/form-data',
270
- },
271
- onUploadProgress: (event) => {
272
- if (!event.total) return;
273
- setAvatarUploadProgress(
274
- Math.round((event.loaded * 100) / event.total)
275
- );
276
- },
277
- });
278
-
279
- const nextAvatarId = Number(data?.id);
280
- if (!nextAvatarId) {
281
- throw new Error('invalid avatar id');
282
- }
283
-
284
- const openResponse = await request<OpenFilePayload>({
285
- url: `/file/open/${nextAvatarId}`,
286
- method: 'PUT',
287
- });
288
-
289
- const openUrl = String(openResponse?.data?.url || '').trim();
290
-
291
- if (previousAvatarId && previousAvatarId !== persistedAvatarId) {
292
- await deleteFileById(previousAvatarId);
293
- }
294
-
295
- setAvatarId(nextAvatarId);
296
- setAvatarPreviewUrl(
297
- openUrl.length > 0
298
- ? /^https?:\/\//i.test(openUrl)
299
- ? openUrl
300
- : `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}${openUrl}`
301
- : getInstructorAvatarUrl(nextAvatarId)
302
- );
303
- setAvatarUploadProgress(100);
304
- toast.success('Foto enviada com sucesso.');
305
- } catch {
306
- toast.error('Nao foi possivel enviar a foto.');
307
- setAvatarUploadProgress(0);
308
- } finally {
309
- setIsUploadingAvatar(false);
310
- if (fileInputRef.current) {
311
- fileInputRef.current.value = '';
312
- }
313
- }
314
- };
315
-
316
- const handleRemoveAvatar = async () => {
317
- if (isUploadingAvatar) return;
318
-
319
- if (avatarId && avatarId !== persistedAvatarId) {
320
- await deleteFileById(avatarId);
321
- }
322
-
323
- setAvatarId(null);
324
- setAvatarPreviewUrl('/placeholder.png');
325
- setAvatarUploadProgress(0);
326
- toast.success('Foto removida com sucesso.');
327
- };
328
-
329
- const handleSubmit = form.handleSubmit(async (values) => {
330
- setSaving(true);
331
- try {
332
- const response = await request<CreatedInstructor>({
333
- url: isEditing
334
- ? `/lms/instructors/${instructorId}`
335
- : '/lms/instructors',
336
- method: isEditing ? 'PATCH' : 'POST',
337
- data: {
338
- name: values.nome.trim(),
339
- email: values.email.trim() || undefined,
340
- phone: values.telefone.trim() || undefined,
341
- avatarId,
342
- qualificationSlugs: values.qualificationSlugs,
343
- status: 'active',
344
- },
345
- });
346
-
347
- const savedInstructor = response.data;
348
-
349
- if (!savedInstructor?.id || !savedInstructor?.name) {
350
- throw new Error('invalid instructor payload');
351
- }
352
-
353
- const savedAvatarId =
354
- typeof savedInstructor.avatarId === 'number'
355
- ? savedInstructor.avatarId
356
- : null;
357
-
358
- form.reset({
359
- nome: savedInstructor.name || values.nome.trim(),
360
- email: savedInstructor.email || values.email.trim(),
361
- telefone: savedInstructor.phone || values.telefone.trim(),
362
- qualificationSlugs:
363
- savedInstructor.qualificationSlugs?.length > 0
364
- ? savedInstructor.qualificationSlugs
365
- : values.qualificationSlugs,
366
- });
367
-
368
- setAvatarId(savedAvatarId);
369
- setPersistedAvatarId(savedAvatarId);
370
- setAvatarPreviewUrl(getInstructorAvatarUrl(savedAvatarId));
371
-
372
- if (!isEditing && onCreated) {
373
- await onCreated(savedInstructor);
374
- }
375
-
376
- if (onSaved) {
377
- await onSaved(savedInstructor);
378
- }
379
-
380
- onOpenChange(false);
381
- toast.success(successMessage);
382
- } catch {
383
- toast.error(errorMessage);
384
- } finally {
385
- setSaving(false);
386
- }
387
- });
388
-
389
- const handleSheetOpenChange = (nextOpen: boolean) => {
390
- if (nextOpen) {
391
- onOpenChange(true);
392
- return;
393
- }
394
-
395
- if (saving || isUploadingAvatar) {
396
- return;
397
- }
398
-
399
- void cleanupUnsavedAvatar();
400
- onOpenChange(false);
401
- };
402
-
403
- return (
404
- <Sheet open={open} onOpenChange={handleSheetOpenChange}>
405
- <SheetContent
406
- side="right"
407
- className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
408
- >
409
- <SheetHeader>
410
- <SheetTitle>{title}</SheetTitle>
411
- <SheetDescription>{description}</SheetDescription>
412
- </SheetHeader>
413
-
414
- {isEditing && loadingInstructorDetails ? (
415
- <div className="flex items-center gap-2 px-4 py-10 text-sm text-muted-foreground">
416
- <Loader2 className="size-4 animate-spin" />
417
- Carregando dados do instrutor...
418
- </div>
419
- ) : (
420
- <form onSubmit={handleSubmit} className="mt-6 space-y-4 px-4">
421
- <Field>
422
- <FieldLabel>Foto</FieldLabel>
423
- <FieldDescription>
424
- JPG, PNG ou GIF. Tamanho maximo de 5MB.
425
- </FieldDescription>
426
- <div className="flex items-center gap-4 rounded-lg border p-4">
427
- <Avatar className="h-22 w-22 rounded-full border">
428
- <AvatarImage
429
- src={avatarPreviewUrl}
430
- alt={form.watch('nome') || 'Instrutor'}
431
- />
432
- <AvatarFallback className="rounded-md text-sm font-semibold uppercase">
433
- {getInstructorInitials(form.watch('nome') || 'Instrutor')}
434
- </AvatarFallback>
435
- </Avatar>
436
-
437
- <div className="flex-1 space-y-2">
438
- <input
439
- ref={fileInputRef}
440
- type="file"
441
- accept="image/*"
442
- className="hidden"
443
- onChange={(event) => {
444
- const file = event.target.files?.[0];
445
- if (file) {
446
- void handleAvatarUpload(file);
447
- }
448
- }}
449
- />
450
-
451
- <div className="flex flex-wrap gap-2">
452
- <Button
453
- type="button"
454
- variant="outline"
455
- onClick={() => fileInputRef.current?.click()}
456
- disabled={isUploadingAvatar}
457
- >
458
- {isUploadingAvatar ? (
459
- <Loader2 className="mr-2 size-4 animate-spin" />
460
- ) : (
461
- <Upload className="mr-2 size-4" />
462
- )}
463
- {avatarId ? 'Trocar foto' : 'Enviar foto'}
464
- </Button>
465
-
466
- {(avatarId || isUploadingAvatar) && (
467
- <Button
468
- type="button"
469
- variant="ghost"
470
- onClick={() => void handleRemoveAvatar()}
471
- disabled={!avatarId || isUploadingAvatar}
472
- >
473
- <Trash2 className="mr-2 size-4" />
474
- Remover
475
- </Button>
476
- )}
477
- </div>
478
-
479
- {isUploadingAvatar && (
480
- <div className="space-y-1">
481
- <Progress value={avatarUploadProgress} className="h-2" />
482
- <p className="text-xs text-muted-foreground">
483
- Enviando foto... {avatarUploadProgress}%
484
- </p>
485
- </div>
486
- )}
487
- </div>
488
- </div>
489
- </Field>
490
-
491
- <Field>
492
- <FieldLabel htmlFor="newInstructorName">Nome</FieldLabel>
493
- <Input
494
- id="newInstructorName"
495
- placeholder="Nome completo"
496
- {...form.register('nome')}
497
- />
498
- <FieldError>{form.formState.errors.nome?.message}</FieldError>
499
- </Field>
500
-
501
- <Field>
502
- <FieldLabel htmlFor="newInstructorEmail">Email</FieldLabel>
503
- <Input
504
- id="newInstructorEmail"
505
- type="email"
506
- placeholder="nome@exemplo.com"
507
- {...form.register('email')}
508
- />
509
- <FieldError>{form.formState.errors.email?.message}</FieldError>
510
- </Field>
511
-
512
- <Field>
513
- <FieldLabel htmlFor="newInstructorPhone">Telefone</FieldLabel>
514
- <Input
515
- id="newInstructorPhone"
516
- placeholder="+55 (11) 90000-0000"
517
- {...form.register('telefone')}
518
- />
519
- <FieldError>{form.formState.errors.telefone?.message}</FieldError>
520
- </Field>
521
-
522
- <Field>
523
- <FieldLabel>Atuacoes</FieldLabel>
524
- <FieldDescription>
525
- Selecione em quais contextos esse instrutor pode atuar.
526
- </FieldDescription>
527
- <Controller
528
- control={form.control}
529
- name="qualificationSlugs"
530
- render={({ field }) => (
531
- <div className="space-y-2 rounded-lg border p-3">
532
- {QUALIFICATION_OPTIONS.map((option) => {
533
- const checked = field.value.includes(option.slug);
534
-
535
- return (
536
- <label
537
- key={option.slug}
538
- className="flex items-start justify-between gap-3 rounded-md border px-3 py-2"
539
- >
540
- <div className="space-y-1">
541
- <p className="text-sm font-medium">
542
- {option.label}
543
- </p>
544
- <p className="text-xs text-muted-foreground">
545
- {option.description}
546
- </p>
547
- </div>
548
- <Switch
549
- checked={checked}
550
- onCheckedChange={(nextChecked) => {
551
- if (nextChecked) {
552
- field.onChange([...field.value, option.slug]);
553
- return;
554
- }
555
-
556
- field.onChange(
557
- field.value.filter(
558
- (value) => value !== option.slug
559
- )
560
- );
561
- }}
562
- />
563
- </label>
564
- );
565
- })}
566
- </div>
567
- )}
568
- />
569
- <FieldError>
570
- {form.formState.errors.qualificationSlugs?.message}
571
- </FieldError>
572
- </Field>
573
-
574
- <SheetFooter className="mt-6 px-0">
575
- <Button
576
- type="submit"
577
- disabled={saving || isUploadingAvatar}
578
- className="gap-2"
579
- >
580
- {(saving || isUploadingAvatar) && (
581
- <Loader2 className="mr-2 size-4 animate-spin" />
582
- )}
583
- {submitLabel}
584
- </Button>
585
- </SheetFooter>
586
- </form>
587
- )}
588
- </SheetContent>
589
- </Sheet>
590
- );
591
- }