@hed-hog/lms 0.0.325 → 0.0.326

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 (69) hide show
  1. package/dist/course/course.service.d.ts +3 -1
  2. package/dist/course/course.service.d.ts.map +1 -1
  3. package/dist/course/course.service.js +35 -5
  4. package/dist/course/course.service.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/instructor/dto/create-instructor-skill.dto.d.ts +9 -0
  10. package/dist/instructor/dto/create-instructor-skill.dto.d.ts.map +1 -0
  11. package/dist/instructor/dto/create-instructor-skill.dto.js +48 -0
  12. package/dist/instructor/dto/create-instructor-skill.dto.js.map +1 -0
  13. package/dist/instructor/dto/create-instructor.dto.d.ts +2 -0
  14. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -1
  15. package/dist/instructor/dto/create-instructor.dto.js +12 -0
  16. package/dist/instructor/dto/create-instructor.dto.js.map +1 -1
  17. package/dist/instructor/dto/update-instructor-skill.dto.d.ts +9 -0
  18. package/dist/instructor/dto/update-instructor-skill.dto.d.ts.map +1 -0
  19. package/dist/instructor/dto/update-instructor-skill.dto.js +50 -0
  20. package/dist/instructor/dto/update-instructor-skill.dto.js.map +1 -0
  21. package/dist/instructor/dto/update-instructor.dto.d.ts +2 -0
  22. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -1
  23. package/dist/instructor/dto/update-instructor.dto.js +12 -0
  24. package/dist/instructor/dto/update-instructor.dto.js.map +1 -1
  25. package/dist/instructor/instructor-skill.controller.d.ts +38 -0
  26. package/dist/instructor/instructor-skill.controller.d.ts.map +1 -0
  27. package/dist/instructor/instructor-skill.controller.js +89 -0
  28. package/dist/instructor/instructor-skill.controller.js.map +1 -0
  29. package/dist/instructor/instructor-skill.service.d.ts +48 -0
  30. package/dist/instructor/instructor-skill.service.d.ts.map +1 -0
  31. package/dist/instructor/instructor-skill.service.js +203 -0
  32. package/dist/instructor/instructor-skill.service.js.map +1 -0
  33. package/dist/instructor/instructor.controller.d.ts +26 -0
  34. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  35. package/dist/instructor/instructor.module.d.ts.map +1 -1
  36. package/dist/instructor/instructor.module.js +4 -2
  37. package/dist/instructor/instructor.module.js.map +1 -1
  38. package/dist/instructor/instructor.service.d.ts +35 -0
  39. package/dist/instructor/instructor.service.d.ts.map +1 -1
  40. package/dist/instructor/instructor.service.js +132 -11
  41. package/dist/instructor/instructor.service.js.map +1 -1
  42. package/dist/training/training.service.d.ts +3 -1
  43. package/dist/training/training.service.d.ts.map +1 -1
  44. package/dist/training/training.service.js +34 -4
  45. package/dist/training/training.service.js.map +1 -1
  46. package/hedhog/data/integration_event_catalog.yaml +219 -0
  47. package/hedhog/data/menu.yaml +23 -6
  48. package/hedhog/data/route.yaml +45 -0
  49. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +1 -1
  50. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +1 -1
  51. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +547 -0
  52. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +845 -239
  53. package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +9 -0
  54. package/hedhog/frontend/app/instructors/page.tsx.ejs +69 -20
  55. package/hedhog/table/instructor.yaml +5 -0
  56. package/hedhog/table/instructor_skill.yaml +26 -0
  57. package/hedhog/table/instructor_skill_assignment.yaml +22 -0
  58. package/package.json +7 -7
  59. package/src/course/course.service.ts +38 -4
  60. package/src/index.ts +2 -0
  61. package/src/instructor/dto/create-instructor-skill.dto.ts +28 -0
  62. package/src/instructor/dto/create-instructor.dto.ts +20 -8
  63. package/src/instructor/dto/update-instructor-skill.dto.ts +30 -0
  64. package/src/instructor/dto/update-instructor.dto.ts +18 -6
  65. package/src/instructor/instructor-skill.controller.ts +60 -0
  66. package/src/instructor/instructor-skill.service.ts +214 -0
  67. package/src/instructor/instructor.module.ts +4 -2
  68. package/src/instructor/instructor.service.ts +148 -0
  69. package/src/training/training.service.ts +38 -4
@@ -1,8 +1,17 @@
1
1
  'use client';
2
2
 
3
+ import { PersonPicker } from '@/app/(app)/(libraries)/contact/_components/person-picker';
4
+ import { PersonFormSheet } from '@/app/(app)/(libraries)/contact/person/_components/person-form-sheet';
5
+ import type {
6
+ ContactTypeOption,
7
+ DocumentTypeOption,
8
+ Person,
9
+ } from '@/app/(app)/(libraries)/contact/person/_components/person-types';
10
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
11
+ import { Badge } from '@/components/ui/badge';
3
12
  import { Button } from '@/components/ui/button';
4
- import { EntityPicker } from '@/components/ui/entity-picker';
5
13
  import { Field, FieldError, FieldLabel } from '@/components/ui/field';
14
+ import { InputMoney } from '@/components/ui/input-money';
6
15
  import {
7
16
  Select,
8
17
  SelectContent,
@@ -19,15 +28,16 @@ import {
19
28
  SheetTitle,
20
29
  } from '@/components/ui/sheet';
21
30
  import { Switch } from '@/components/ui/switch';
31
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
22
32
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
33
  import { zodResolver } from '@hookform/resolvers/zod';
24
- import { Loader2, Plus } from 'lucide-react';
34
+ import { useQueryClient } from '@tanstack/react-query';
35
+ import { BookOpen, Loader2, Pencil, Star, X } from 'lucide-react';
25
36
  import { useEffect, useState } from 'react';
26
37
  import { Controller, useForm } from 'react-hook-form';
27
38
  import { toast } from 'sonner';
28
39
  import { z } from 'zod';
29
- import { CreateLmsPersonSheet } from '../../_components/create-lms-person-sheet';
30
- import type { InstructorRow, PersonOption } from './instructor-types';
40
+ import type { InstructorRow, InstructorSkill } from './instructor-types';
31
41
 
32
42
  const QUALIFICATION_OPTIONS = [
33
43
  {
@@ -52,10 +62,32 @@ const instructorFormSchema = z.object({
52
62
  .min(1, 'Selecione pelo menos uma qualificação'),
53
63
  status: z.enum(['active', 'inactive']),
54
64
  can_teach_courses: z.boolean(),
65
+ hourlyRate: z.coerce.number().min(0).optional().nullable(),
66
+ skillSlugs: z.array(z.string()).optional().default([]),
55
67
  });
56
68
 
57
69
  type InstructorFormValues = z.infer<typeof instructorFormSchema>;
58
70
 
71
+ type ClassGroupItem = {
72
+ id: number;
73
+ name: string;
74
+ code: string;
75
+ courseName: string | null;
76
+ startDate: string | null;
77
+ endDate: string | null;
78
+ status: string;
79
+ slots: number;
80
+ };
81
+
82
+ const STATUS_LABEL: Record<string, string> = {
83
+ active: 'Ativo',
84
+ inactive: 'Inativo',
85
+ in_progress: 'Em andamento',
86
+ upcoming: 'Futuro',
87
+ completed: 'Concluído',
88
+ cancelled: 'Cancelado',
89
+ };
90
+
59
91
  type InstructorFormSheetProps = {
60
92
  open: boolean;
61
93
  onOpenChange: (open: boolean) => void;
@@ -69,18 +101,25 @@ export function InstructorFormSheet({
69
101
  instructorId,
70
102
  onSaved,
71
103
  }: InstructorFormSheetProps) {
72
- const { request } = useApp();
104
+ const { request, currentLocaleCode } = useApp();
105
+ const queryClient = useQueryClient();
73
106
  const [saving, setSaving] = useState(false);
74
- const [createPersonOpen, setCreatePersonOpen] = useState(false);
107
+ const [selectedPersonName, setSelectedPersonName] = useState('');
75
108
  const [trainingAccess, setTrainingAccess] = useState<boolean | null>(null);
76
109
  const [togglingAccess, setTogglingAccess] = useState(false);
110
+ const [editPersonOpen, setEditPersonOpen] = useState(false);
111
+ const [personToEdit, setPersonToEdit] = useState<Person | null>(null);
112
+ const [activeTab, setActiveTab] = useState('detalhes');
113
+ const [tabSkillSlugs, setTabSkillSlugs] = useState<string[]>([]);
114
+ const [savingSkills, setSavingSkills] = useState(false);
115
+ const [addSkillValue, setAddSkillValue] = useState('');
77
116
  const isEditing = typeof instructorId === 'number' && instructorId > 0;
78
117
 
79
118
  const {
80
119
  control,
81
120
  handleSubmit,
82
121
  reset,
83
- setValue,
122
+ watch,
84
123
  formState: { errors },
85
124
  } = useForm<InstructorFormValues>({
86
125
  resolver: zodResolver(instructorFormSchema),
@@ -92,6 +131,44 @@ export function InstructorFormSheet({
92
131
  },
93
132
  });
94
133
 
134
+ const watchedPersonId = watch('personId');
135
+
136
+ const { data: contactTypes = [] } = useQuery<ContactTypeOption[]>({
137
+ queryKey: ['contact-person-contact-types', currentLocaleCode],
138
+ queryFn: async () => {
139
+ const response = await request<{ data: ContactTypeOption[] }>({
140
+ url: '/person-contact-type?pageSize=100',
141
+ method: 'GET',
142
+ });
143
+ return response.data.data || [];
144
+ },
145
+ enabled: open,
146
+ placeholderData: (previous) => previous ?? [],
147
+ });
148
+
149
+ const { data: documentTypes = [] } = useQuery<DocumentTypeOption[]>({
150
+ queryKey: ['contact-person-document-types', currentLocaleCode],
151
+ queryFn: async () => {
152
+ const response = await request<{ data: DocumentTypeOption[] }>({
153
+ url: '/person-document-type?pageSize=100',
154
+ method: 'GET',
155
+ });
156
+ return response.data.data || [];
157
+ },
158
+ enabled: open,
159
+ placeholderData: (previous) => previous ?? [],
160
+ });
161
+
162
+ const handleOpenPersonEdit = async () => {
163
+ if (!watchedPersonId) return;
164
+ const response = await request<Person>({
165
+ url: `/person/${watchedPersonId}`,
166
+ method: 'GET',
167
+ });
168
+ setPersonToEdit(response.data);
169
+ setEditPersonOpen(true);
170
+ };
171
+
95
172
  const { data: existingInstructor } = useQuery<InstructorRow | null>({
96
173
  queryKey: ['lms-instructor-detail', instructorId],
97
174
  queryFn: async () => {
@@ -106,6 +183,38 @@ export function InstructorFormSheet({
106
183
  placeholderData: null,
107
184
  });
108
185
 
186
+ const { data: skillsData = [] } = useQuery<InstructorSkill[]>({
187
+ queryKey: ['lms-instructor-skills-all'],
188
+ queryFn: async () => {
189
+ const response = await request<InstructorSkill[]>({
190
+ url: '/lms/instructor-skills/all',
191
+ method: 'GET',
192
+ });
193
+ return response.data;
194
+ },
195
+ });
196
+
197
+ // Turmas — enterprise/training/admin endpoint supports instructorId filter
198
+ const { data: turmasData = [], isLoading: loadingTurmas } = useQuery<
199
+ ClassGroupItem[]
200
+ >({
201
+ queryKey: ['lms-instructor-turmas', instructorId],
202
+ queryFn: async () => {
203
+ const response = await request<
204
+ { data?: ClassGroupItem[] } | ClassGroupItem[]
205
+ >({
206
+ url: `/lms/enterprise/training/admin/class-groups?instructorId=${instructorId}`,
207
+ method: 'GET',
208
+ });
209
+ const raw = response.data;
210
+ if (Array.isArray(raw)) return raw;
211
+ if (raw && 'data' in raw && Array.isArray(raw.data)) return raw.data;
212
+ return [];
213
+ },
214
+ enabled: isEditing && open && activeTab === 'turmas',
215
+ placeholderData: [],
216
+ });
217
+
109
218
  useEffect(() => {
110
219
  if (isEditing && existingInstructor) {
111
220
  reset({
@@ -116,19 +225,32 @@ export function InstructorFormSheet({
116
225
  : ['course-lessons'],
117
226
  status: existingInstructor.status,
118
227
  can_teach_courses: true,
228
+ hourlyRate: existingInstructor.hourlyRate ?? null,
229
+ skillSlugs: (existingInstructor.skills ?? []).map((s) => s.slug),
119
230
  });
231
+ setSelectedPersonName(existingInstructor.name);
120
232
  setTrainingAccess(existingInstructor.hasTrainingAccess ?? false);
233
+ setTabSkillSlugs((existingInstructor.skills ?? []).map((s) => s.slug));
121
234
  } else if (!isEditing && open) {
122
235
  reset({
123
236
  personId: undefined,
124
237
  qualificationSlugs: ['course-lessons'],
125
238
  status: 'active',
126
239
  can_teach_courses: true,
240
+ hourlyRate: null,
241
+ skillSlugs: [],
127
242
  });
243
+ setSelectedPersonName('');
128
244
  setTrainingAccess(null);
245
+ setTabSkillSlugs([]);
129
246
  }
130
247
  }, [isEditing, existingInstructor, open, reset]);
131
248
 
249
+ // Reset to first tab when sheet closes
250
+ useEffect(() => {
251
+ if (!open) setActiveTab('detalhes');
252
+ }, [open]);
253
+
132
254
  const handleSheetClose = (nextOpen: boolean) => {
133
255
  if (!nextOpen) {
134
256
  reset();
@@ -136,6 +258,27 @@ export function InstructorFormSheet({
136
258
  onOpenChange(nextOpen);
137
259
  };
138
260
 
261
+ const handleSaveSkills = async () => {
262
+ if (!instructorId) return;
263
+ setSavingSkills(true);
264
+ try {
265
+ await request({
266
+ url: `/lms/instructors/${instructorId}`,
267
+ method: 'PATCH',
268
+ data: { skillSlugs: tabSkillSlugs },
269
+ });
270
+ await queryClient.invalidateQueries({
271
+ queryKey: ['lms-instructor-detail', instructorId],
272
+ });
273
+ await queryClient.invalidateQueries({ queryKey: ['lms-instructors'] });
274
+ toast.success('Skills atualizadas com sucesso.');
275
+ } catch {
276
+ toast.error('Erro ao salvar skills.');
277
+ } finally {
278
+ setSavingSkills(false);
279
+ }
280
+ };
281
+
139
282
  const onSubmit = async (values: InstructorFormValues) => {
140
283
  try {
141
284
  setSaving(true);
@@ -147,8 +290,11 @@ export function InstructorFormSheet({
147
290
  url: `/lms/instructors/${instructorId}`,
148
291
  method: 'PATCH',
149
292
  data: {
293
+ personId: values.personId,
150
294
  qualificationSlugs: values.qualificationSlugs,
151
295
  status: values.status,
296
+ hourlyRate: values.hourlyRate ?? null,
297
+ skillSlugs: values.skillSlugs ?? [],
152
298
  },
153
299
  });
154
300
  result = response.data;
@@ -162,6 +308,8 @@ export function InstructorFormSheet({
162
308
  name: '',
163
309
  qualificationSlugs: values.qualificationSlugs,
164
310
  status: values.status,
311
+ hourlyRate: values.hourlyRate ?? null,
312
+ skillSlugs: values.skillSlugs ?? [],
165
313
  },
166
314
  });
167
315
  result = response.data;
@@ -186,253 +334,711 @@ export function InstructorFormSheet({
186
334
  <Sheet open={open} onOpenChange={handleSheetClose}>
187
335
  <SheetContent
188
336
  side="right"
189
- className="flex w-full max-w-md flex-col overflow-y-auto sm:max-w-lg"
337
+ className="flex w-full max-w-lg flex-col overflow-hidden p-0 sm:max-w-xl"
190
338
  >
191
- <SheetHeader>
192
- <SheetTitle>
193
- {isEditing ? 'Editar Instrutor' : 'Novo Instrutor'}
194
- </SheetTitle>
195
- <SheetDescription>
196
- {isEditing
197
- ? 'Atualize os dados do perfil do instrutor.'
198
- : 'Vincule uma pessoa existente ou crie uma nova para cadastrá-la como instrutora.'}
199
- </SheetDescription>
200
- </SheetHeader>
201
-
202
- <form
203
- onSubmit={handleSubmit(onSubmit)}
204
- className="mt-6 flex flex-1 flex-col gap-5 px-4"
205
- >
206
- {/* Person picker */}
207
- {!isEditing && (
208
- <Field>
209
- <FieldLabel>
210
- Pessoa <span className="text-destructive">*</span>
211
- </FieldLabel>
212
- <div className="flex items-center gap-2">
213
- <div className="min-w-0 flex-1">
214
- <Controller
215
- control={control}
216
- name="personId"
217
- render={({ field }) => (
218
- <EntityPicker
219
- value={field.value ?? null}
220
- onChange={(val) =>
221
- field.onChange(
222
- val !== null ? Number(val) : undefined
223
- )
339
+ {/* ── Instructor header (edit mode) ── */}
340
+ {isEditing && existingInstructor ? (
341
+ <div className="flex shrink-0 items-center gap-4 border-b px-6 py-4">
342
+ <Avatar className="h-14 w-14">
343
+ <AvatarImage
344
+ src={
345
+ existingInstructor.avatarId
346
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${existingInstructor.avatarId}`
347
+ : undefined
348
+ }
349
+ alt={existingInstructor.name}
350
+ />
351
+ <AvatarFallback>
352
+ {existingInstructor.name.slice(0, 2).toUpperCase()}
353
+ </AvatarFallback>
354
+ </Avatar>
355
+ <div className="min-w-0 flex-1">
356
+ <h2 className="truncate text-lg font-semibold">
357
+ {existingInstructor.name}
358
+ </h2>
359
+ <div className="mt-1 flex flex-wrap items-center gap-2">
360
+ <Badge
361
+ variant={
362
+ existingInstructor.status === 'active'
363
+ ? 'default'
364
+ : 'secondary'
365
+ }
366
+ >
367
+ {existingInstructor.status === 'active'
368
+ ? 'Ativo'
369
+ : 'Inativo'}
370
+ </Badge>
371
+ {existingInstructor.email && (
372
+ <span className="truncate text-xs text-muted-foreground">
373
+ {existingInstructor.email}
374
+ </span>
375
+ )}
376
+ {existingInstructor.phone && (
377
+ <span className="text-xs text-muted-foreground">
378
+ {existingInstructor.phone}
379
+ </span>
380
+ )}
381
+ </div>
382
+ </div>
383
+ </div>
384
+ ) : (
385
+ <SheetHeader className="shrink-0 border-b px-6 py-4">
386
+ <SheetTitle>Novo Instrutor</SheetTitle>
387
+ <SheetDescription>
388
+ Vincule uma pessoa existente ou crie uma nova para cadastrá-la
389
+ como instrutora.
390
+ </SheetDescription>
391
+ </SheetHeader>
392
+ )}
393
+
394
+ {/* ── Tabs (edit) / plain form (create) ── */}
395
+ {isEditing ? (
396
+ <Tabs
397
+ value={activeTab}
398
+ onValueChange={setActiveTab}
399
+ className="flex min-h-0 flex-1 flex-col"
400
+ >
401
+ <TabsList className="mx-6 mt-3 shrink-0 justify-start">
402
+ <TabsTrigger value="detalhes">Detalhes</TabsTrigger>
403
+ <TabsTrigger value="skills">Skills</TabsTrigger>
404
+ <TabsTrigger value="turmas">Turmas</TabsTrigger>
405
+ <TabsTrigger value="avaliacoes">Avaliações</TabsTrigger>
406
+ </TabsList>
407
+
408
+ {/* ── Detalhes ── */}
409
+ <TabsContent
410
+ value="detalhes"
411
+ className="flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
412
+ >
413
+ <form
414
+ onSubmit={handleSubmit(onSubmit)}
415
+ className="flex min-h-0 flex-1 flex-col"
416
+ >
417
+ <div className="flex-1 overflow-y-auto px-6 py-4">
418
+ <div className="flex flex-col gap-5">
419
+ {/* Person picker */}
420
+ <Field>
421
+ <FieldLabel>
422
+ Pessoa <span className="text-destructive">*</span>
423
+ </FieldLabel>
424
+ <div className="flex items-center gap-2">
425
+ <div className="min-w-0 flex-1">
426
+ <Controller
427
+ control={control}
428
+ name="personId"
429
+ render={({ field }) => (
430
+ <PersonPicker
431
+ label=""
432
+ entityLabel="pessoa"
433
+ value={field.value ?? null}
434
+ initialSelectedLabel={selectedPersonName}
435
+ onChange={(personId, personName) => {
436
+ field.onChange(personId ?? undefined);
437
+ setSelectedPersonName(personName);
438
+ }}
439
+ selectPlaceholder="Buscar ou selecionar pessoa..."
440
+ personTypeFilter="individual"
441
+ createType="individual"
442
+ lockCreateType
443
+ />
444
+ )}
445
+ />
446
+ </div>
447
+ <Button
448
+ type="button"
449
+ variant="outline"
450
+ size="icon"
451
+ className="shrink-0"
452
+ disabled={!watchedPersonId}
453
+ onClick={handleOpenPersonEdit}
454
+ aria-label="Editar cadastro da pessoa"
455
+ title="Editar cadastro da pessoa"
456
+ >
457
+ <Pencil className="h-4 w-4" />
458
+ </Button>
459
+ </div>
460
+ {errors.personId && (
461
+ <FieldError>{errors.personId.message}</FieldError>
462
+ )}
463
+ </Field>
464
+
465
+ {/* Status */}
466
+ <Field>
467
+ <FieldLabel>Status</FieldLabel>
468
+ <Controller
469
+ control={control}
470
+ name="status"
471
+ render={({ field }) => (
472
+ <Select
473
+ value={field.value}
474
+ onValueChange={field.onChange}
475
+ >
476
+ <SelectTrigger className="w-full">
477
+ <SelectValue placeholder="Selecione o status" />
478
+ </SelectTrigger>
479
+ <SelectContent>
480
+ <SelectItem value="active">Ativo</SelectItem>
481
+ <SelectItem value="inactive">
482
+ Inativo
483
+ </SelectItem>
484
+ </SelectContent>
485
+ </Select>
486
+ )}
487
+ />
488
+ </Field>
489
+
490
+ {/* Valor/hora */}
491
+ <Field>
492
+ <FieldLabel>Valor/hora (R$)</FieldLabel>
493
+ <Controller
494
+ control={control}
495
+ name="hourlyRate"
496
+ render={({ field }) => (
497
+ <InputMoney
498
+ placeholder="Ex: 150,00"
499
+ value={field.value ?? ''}
500
+ onValueChange={(value) => field.onChange(value)}
501
+ />
502
+ )}
503
+ />
504
+ </Field>
505
+
506
+ {/* can_teach_courses */}
507
+ <div className="flex items-center justify-between rounded-lg border p-4">
508
+ <div className="space-y-0.5">
509
+ <p className="text-sm font-medium">
510
+ Pode ensinar cursos
511
+ </p>
512
+ <p className="text-xs text-muted-foreground">
513
+ Permite que o instrutor seja vinculado a aulas de
514
+ curso.
515
+ </p>
516
+ </div>
517
+ <Controller
518
+ control={control}
519
+ name="can_teach_courses"
520
+ render={({ field }) => (
521
+ <Switch
522
+ checked={field.value}
523
+ onCheckedChange={field.onChange}
524
+ />
525
+ )}
526
+ />
527
+ </div>
528
+
529
+ {/* Acesso ao Training */}
530
+ <div className="flex items-center justify-between rounded-lg border p-4">
531
+ <div className="space-y-0.5">
532
+ <p className="text-sm font-medium">
533
+ Acesso ao Hcode Training
534
+ </p>
535
+ <p className="text-xs text-muted-foreground">
536
+ {existingInstructor?.userId
537
+ ? 'Permite que este instrutor acesse a plataforma com o perfil de instrutor.'
538
+ : 'Nenhum usuário vinculado a este cadastro. Vincule um usuário para habilitar.'}
539
+ </p>
540
+ </div>
541
+ <Switch
542
+ checked={trainingAccess ?? false}
543
+ disabled={
544
+ !existingInstructor?.userId || togglingAccess
224
545
  }
225
- valueType="number"
226
- loadOptions={async ({ page, pageSize, search }) => {
227
- const response = await request<{
228
- data: PersonOption[];
229
- lastPage?: number;
230
- }>({
231
- url: '/person',
232
- method: 'GET',
233
- params: {
234
- page,
235
- pageSize,
236
- type: 'individual',
237
- ...(search.trim()
238
- ? { search: search.trim() }
239
- : {}),
240
- },
241
- });
242
- return {
243
- items: (response.data?.data ?? []).map((p) => ({
244
- id: p.id,
245
- name: p.name,
246
- })),
247
- hasMore: page < (response.data?.lastPage ?? 1),
248
- };
546
+ onCheckedChange={async (next) => {
547
+ setTogglingAccess(true);
548
+ try {
549
+ await request({
550
+ url: `/lms/instructors/${instructorId}/training-access`,
551
+ method: 'PATCH',
552
+ data: { enabled: next },
553
+ });
554
+ setTrainingAccess(next);
555
+ toast.success(
556
+ next
557
+ ? 'Acesso ao Training habilitado.'
558
+ : 'Acesso ao Training desabilitado.'
559
+ );
560
+ } catch {
561
+ toast.error(
562
+ 'Erro ao alterar acesso ao Training.'
563
+ );
564
+ } finally {
565
+ setTogglingAccess(false);
566
+ }
249
567
  }}
250
- getOptionValue={(opt) => opt.id as number}
251
- getOptionLabel={(opt) => opt.name ?? ''}
252
- placeholder="Buscar pessoa por nome..."
253
- searchable
254
- clearable
255
- showCreateButton={false}
256
568
  />
569
+ </div>
570
+
571
+ {/* Qualificações */}
572
+ <Field>
573
+ <FieldLabel>
574
+ Qualificações{' '}
575
+ <span className="text-destructive">*</span>
576
+ </FieldLabel>
577
+ <div className="space-y-3">
578
+ {QUALIFICATION_OPTIONS.map((option) => (
579
+ <Controller
580
+ key={option.slug}
581
+ control={control}
582
+ name="qualificationSlugs"
583
+ render={({ field }) => {
584
+ const checked = field.value.includes(
585
+ option.slug
586
+ );
587
+ return (
588
+ <div className="flex items-start justify-between rounded-lg border p-3">
589
+ <div className="space-y-0.5">
590
+ <p className="text-sm font-medium">
591
+ {option.label}
592
+ </p>
593
+ <p className="text-xs text-muted-foreground">
594
+ {option.description}
595
+ </p>
596
+ </div>
597
+ <Switch
598
+ checked={checked}
599
+ onCheckedChange={(next) => {
600
+ if (next) {
601
+ field.onChange([
602
+ ...field.value,
603
+ option.slug,
604
+ ]);
605
+ } else {
606
+ field.onChange(
607
+ field.value.filter(
608
+ (s) => s !== option.slug
609
+ )
610
+ );
611
+ }
612
+ }}
613
+ />
614
+ </div>
615
+ );
616
+ }}
617
+ />
618
+ ))}
619
+ </div>
620
+ {errors.qualificationSlugs && (
621
+ <FieldError>
622
+ {errors.qualificationSlugs.message}
623
+ </FieldError>
624
+ )}
625
+ </Field>
626
+ </div>
627
+ </div>
628
+
629
+ <SheetFooter className="shrink-0 border-t px-6 py-4">
630
+ <Button
631
+ type="button"
632
+ variant="outline"
633
+ onClick={() => handleSheetClose(false)}
634
+ disabled={saving}
635
+ >
636
+ Cancelar
637
+ </Button>
638
+ <Button type="submit" disabled={saving}>
639
+ {saving && (
640
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
257
641
  )}
258
- />
642
+ Salvar alterações
643
+ </Button>
644
+ </SheetFooter>
645
+ </form>
646
+ </TabsContent>
647
+
648
+ {/* ── Skills ── */}
649
+ <TabsContent
650
+ value="skills"
651
+ className="flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
652
+ >
653
+ <div className="flex min-h-0 flex-1 flex-col">
654
+ <div className="flex-1 overflow-y-auto px-6 py-4">
655
+ <p className="mb-4 text-sm text-muted-foreground">
656
+ Gerencie as skills associadas a este instrutor.
657
+ </p>
658
+
659
+ {tabSkillSlugs.length > 0 ? (
660
+ <div className="mb-6 flex flex-wrap gap-2">
661
+ {tabSkillSlugs.map((slug) => {
662
+ const skill = skillsData.find((s) => s.slug === slug);
663
+ return (
664
+ <Badge
665
+ key={slug}
666
+ variant="secondary"
667
+ className="flex items-center gap-1 pr-1"
668
+ >
669
+ <span>{skill?.name ?? slug}</span>
670
+ <button
671
+ type="button"
672
+ className="ml-1 rounded-full p-0.5 hover:bg-muted-foreground/20"
673
+ onClick={() =>
674
+ setTabSkillSlugs((prev) =>
675
+ prev.filter((s) => s !== slug)
676
+ )
677
+ }
678
+ aria-label={`Remover skill ${skill?.name ?? slug}`}
679
+ >
680
+ <X className="h-3 w-3" />
681
+ </button>
682
+ </Badge>
683
+ );
684
+ })}
685
+ </div>
686
+ ) : (
687
+ <p className="mb-6 italic text-sm text-muted-foreground">
688
+ Nenhuma skill atribuída.
689
+ </p>
690
+ )}
691
+
692
+ {skillsData.filter((s) => !tabSkillSlugs.includes(s.slug))
693
+ .length > 0 && (
694
+ <Select
695
+ value={addSkillValue}
696
+ onValueChange={(val) => {
697
+ if (val) {
698
+ setTabSkillSlugs((prev) => [...prev, val]);
699
+ setAddSkillValue('');
700
+ }
701
+ }}
702
+ >
703
+ <SelectTrigger className="w-full">
704
+ <SelectValue placeholder="Adicionar skill..." />
705
+ </SelectTrigger>
706
+ <SelectContent>
707
+ {skillsData
708
+ .filter((s) => !tabSkillSlugs.includes(s.slug))
709
+ .map((skill) => (
710
+ <SelectItem key={skill.slug} value={skill.slug}>
711
+ {skill.name}
712
+ </SelectItem>
713
+ ))}
714
+ </SelectContent>
715
+ </Select>
716
+ )}
717
+ </div>
718
+
719
+ <div className="shrink-0 border-t px-6 py-4">
720
+ <div className="flex justify-end gap-2">
721
+ <Button
722
+ type="button"
723
+ variant="outline"
724
+ onClick={() => handleSheetClose(false)}
725
+ disabled={savingSkills}
726
+ >
727
+ Cancelar
728
+ </Button>
729
+ <Button
730
+ type="button"
731
+ onClick={handleSaveSkills}
732
+ disabled={savingSkills}
733
+ >
734
+ {savingSkills && (
735
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
736
+ )}
737
+ Salvar skills
738
+ </Button>
739
+ </div>
259
740
  </div>
260
- <Button
261
- type="button"
262
- variant="outline"
263
- size="icon"
264
- className="shrink-0"
265
- onClick={() => setCreatePersonOpen(true)}
266
- aria-label="Criar nova pessoa"
267
- title="Criar nova pessoa"
268
- >
269
- <Plus className="h-4 w-4" />
270
- </Button>
271
741
  </div>
272
- {errors.personId && (
273
- <FieldError>{errors.personId.message}</FieldError>
274
- )}
275
- </Field>
276
- )}
277
-
278
- {/* Status */}
279
- <Field>
280
- <FieldLabel>Status</FieldLabel>
281
- <Controller
282
- control={control}
283
- name="status"
284
- render={({ field }) => (
285
- <Select value={field.value} onValueChange={field.onChange}>
286
- <SelectTrigger>
287
- <SelectValue placeholder="Selecione o status" />
288
- </SelectTrigger>
289
- <SelectContent>
290
- <SelectItem value="active">Ativo</SelectItem>
291
- <SelectItem value="inactive">Inativo</SelectItem>
292
- </SelectContent>
293
- </Select>
294
- )}
295
- />
296
- </Field>
297
-
298
- {/* can_teach_courses */}
299
- <div className="flex items-center justify-between rounded-lg border p-4">
300
- <div className="space-y-0.5">
301
- <p className="text-sm font-medium">Pode ensinar cursos</p>
302
- <p className="text-xs text-muted-foreground">
303
- Permite que o instrutor seja vinculado a aulas de curso.
304
- </p>
305
- </div>
306
- <Controller
307
- control={control}
308
- name="can_teach_courses"
309
- render={({ field }) => (
310
- <Switch
311
- checked={field.value}
312
- onCheckedChange={field.onChange}
313
- />
314
- )}
315
- />
316
- </div>
742
+ </TabsContent>
317
743
 
318
- {/* Acesso ao Training */}
319
- {isEditing && (
320
- <div className="flex items-center justify-between rounded-lg border p-4">
321
- <div className="space-y-0.5">
322
- <p className="text-sm font-medium">
323
- Acesso ao Hcode Training
744
+ {/* ── Turmas ── */}
745
+ <TabsContent
746
+ value="turmas"
747
+ className="flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
748
+ >
749
+ <div className="flex-1 overflow-y-auto px-6 py-4">
750
+ {loadingTurmas ? (
751
+ <div className="flex items-center justify-center py-12">
752
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
753
+ </div>
754
+ ) : turmasData.length === 0 ? (
755
+ <div className="flex flex-col items-center justify-center py-12 text-center">
756
+ <BookOpen className="mb-3 h-10 w-10 text-muted-foreground/40" />
757
+ <p className="text-sm font-medium text-muted-foreground">
758
+ Nenhuma turma encontrada
759
+ </p>
760
+ <p className="mt-1 text-xs text-muted-foreground">
761
+ Este instrutor ainda não está vinculado a nenhuma turma.
762
+ </p>
763
+ </div>
764
+ ) : (
765
+ <div className="space-y-2">
766
+ {turmasData.map((turma) => (
767
+ <div key={turma.id} className="rounded-lg border p-3">
768
+ <div className="flex items-start justify-between gap-2">
769
+ <div className="min-w-0 flex-1">
770
+ <p className="truncate text-sm font-medium">
771
+ {turma.name}
772
+ </p>
773
+ {turma.courseName && (
774
+ <p className="truncate text-xs text-muted-foreground">
775
+ {turma.courseName}
776
+ </p>
777
+ )}
778
+ </div>
779
+ <Badge
780
+ variant="outline"
781
+ className="shrink-0 text-xs"
782
+ >
783
+ {STATUS_LABEL[turma.status] ?? turma.status}
784
+ </Badge>
785
+ </div>
786
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
787
+ {turma.startDate && (
788
+ <span>
789
+ Início:{' '}
790
+ {new Date(turma.startDate).toLocaleDateString(
791
+ 'pt-BR'
792
+ )}
793
+ </span>
794
+ )}
795
+ {turma.endDate && (
796
+ <span>
797
+ Fim:{' '}
798
+ {new Date(turma.endDate).toLocaleDateString(
799
+ 'pt-BR'
800
+ )}
801
+ </span>
802
+ )}
803
+ {turma.slots > 0 && (
804
+ <span>{turma.slots} aluno(s)</span>
805
+ )}
806
+ </div>
807
+ </div>
808
+ ))}
809
+ </div>
810
+ )}
811
+ </div>
812
+ </TabsContent>
813
+
814
+ {/* ── Avaliações ── */}
815
+ <TabsContent
816
+ value="avaliacoes"
817
+ className="flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
818
+ >
819
+ {/* TODO: implementar endpoint de avaliações de instrutor */}
820
+ <div className="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
821
+ <Star className="mb-3 h-10 w-10 text-muted-foreground/40" />
822
+ <p className="text-sm font-medium text-muted-foreground">
823
+ Avaliações — funcionalidade em breve
324
824
  </p>
325
- <p className="text-xs text-muted-foreground">
326
- {existingInstructor?.userId
327
- ? 'Permite que este instrutor acesse a plataforma com o perfil de instrutor.'
328
- : 'Nenhum usuário vinculado a este cadastro. Vincule um usuário para habilitar.'}
825
+ <p className="mt-1 text-xs text-muted-foreground">
826
+ Em breve você poderá visualizar as avaliações recebidas
827
+ pelos alunos.
329
828
  </p>
330
829
  </div>
331
- <Switch
332
- checked={trainingAccess ?? false}
333
- disabled={!existingInstructor?.userId || togglingAccess}
334
- onCheckedChange={async (next) => {
335
- setTogglingAccess(true);
336
- try {
337
- await request({
338
- url: `/lms/instructors/${instructorId}/training-access`,
339
- method: 'PATCH',
340
- data: { enabled: next },
341
- });
342
- setTrainingAccess(next);
343
- toast.success(
344
- next
345
- ? 'Acesso ao Training habilitado.'
346
- : 'Acesso ao Training desabilitado.'
347
- );
348
- } catch {
349
- toast.error('Erro ao alterar acesso ao Training.');
350
- } finally {
351
- setTogglingAccess(false);
352
- }
353
- }}
354
- />
355
- </div>
356
- )}
357
-
358
- {/* Qualificações */}
359
- <Field>
360
- <FieldLabel>
361
- Qualificações <span className="text-destructive">*</span>
362
- </FieldLabel>
363
- <div className="space-y-3">
364
- {QUALIFICATION_OPTIONS.map((option) => (
365
- <Controller
366
- key={option.slug}
367
- control={control}
368
- name="qualificationSlugs"
369
- render={({ field }) => {
370
- const checked = field.value.includes(option.slug);
371
- return (
372
- <div className="flex items-start justify-between rounded-lg border p-3">
373
- <div className="space-y-0.5">
374
- <p className="text-sm font-medium">
375
- {option.label}
376
- </p>
377
- <p className="text-xs text-muted-foreground">
378
- {option.description}
379
- </p>
380
- </div>
381
- <Switch
382
- checked={checked}
383
- onCheckedChange={(next) => {
384
- if (next) {
385
- field.onChange([...field.value, option.slug]);
386
- } else {
387
- field.onChange(
388
- field.value.filter((s) => s !== option.slug)
389
- );
390
- }
391
- }}
392
- />
393
- </div>
394
- );
395
- }}
396
- />
397
- ))}
830
+ </TabsContent>
831
+ </Tabs>
832
+ ) : (
833
+ /* ── Create mode: plain form ── */
834
+ <form
835
+ onSubmit={handleSubmit(onSubmit)}
836
+ className="flex min-h-0 flex-1 flex-col"
837
+ >
838
+ <div className="flex-1 overflow-y-auto px-6 py-4">
839
+ <div className="flex flex-col gap-5">
840
+ {/* Person picker */}
841
+ <Field>
842
+ <FieldLabel>
843
+ Pessoa <span className="text-destructive">*</span>
844
+ </FieldLabel>
845
+ <div className="flex items-center gap-2">
846
+ <div className="min-w-0 flex-1">
847
+ <Controller
848
+ control={control}
849
+ name="personId"
850
+ render={({ field }) => (
851
+ <PersonPicker
852
+ label=""
853
+ entityLabel="pessoa"
854
+ value={field.value ?? null}
855
+ initialSelectedLabel={selectedPersonName}
856
+ onChange={(personId, personName) => {
857
+ field.onChange(personId ?? undefined);
858
+ setSelectedPersonName(personName);
859
+ }}
860
+ selectPlaceholder="Buscar ou selecionar pessoa..."
861
+ personTypeFilter="individual"
862
+ createType="individual"
863
+ lockCreateType
864
+ />
865
+ )}
866
+ />
867
+ </div>
868
+ <Button
869
+ type="button"
870
+ variant="outline"
871
+ size="icon"
872
+ className="shrink-0"
873
+ disabled={!watchedPersonId}
874
+ onClick={handleOpenPersonEdit}
875
+ aria-label="Editar cadastro da pessoa"
876
+ title="Editar cadastro da pessoa"
877
+ >
878
+ <Pencil className="h-4 w-4" />
879
+ </Button>
880
+ </div>
881
+ {errors.personId && (
882
+ <FieldError>{errors.personId.message}</FieldError>
883
+ )}
884
+ </Field>
885
+
886
+ {/* Status */}
887
+ <Field>
888
+ <FieldLabel>Status</FieldLabel>
889
+ <Controller
890
+ control={control}
891
+ name="status"
892
+ render={({ field }) => (
893
+ <Select
894
+ value={field.value}
895
+ onValueChange={field.onChange}
896
+ >
897
+ <SelectTrigger className="w-full">
898
+ <SelectValue placeholder="Selecione o status" />
899
+ </SelectTrigger>
900
+ <SelectContent>
901
+ <SelectItem value="active">Ativo</SelectItem>
902
+ <SelectItem value="inactive">Inativo</SelectItem>
903
+ </SelectContent>
904
+ </Select>
905
+ )}
906
+ />
907
+ </Field>
908
+
909
+ {/* Valor/hora */}
910
+ <Field>
911
+ <FieldLabel>Valor/hora (R$)</FieldLabel>
912
+ <Controller
913
+ control={control}
914
+ name="hourlyRate"
915
+ render={({ field }) => (
916
+ <InputMoney
917
+ placeholder="Ex: 150,00"
918
+ value={field.value ?? ''}
919
+ onValueChange={(value) => field.onChange(value)}
920
+ />
921
+ )}
922
+ />
923
+ </Field>
924
+
925
+ {/* can_teach_courses */}
926
+ <div className="flex items-center justify-between rounded-lg border p-4">
927
+ <div className="space-y-0.5">
928
+ <p className="text-sm font-medium">Pode ensinar cursos</p>
929
+ <p className="text-xs text-muted-foreground">
930
+ Permite que o instrutor seja vinculado a aulas de curso.
931
+ </p>
932
+ </div>
933
+ <Controller
934
+ control={control}
935
+ name="can_teach_courses"
936
+ render={({ field }) => (
937
+ <Switch
938
+ checked={field.value}
939
+ onCheckedChange={field.onChange}
940
+ />
941
+ )}
942
+ />
943
+ </div>
944
+
945
+ {/* Qualificações */}
946
+ <Field>
947
+ <FieldLabel>
948
+ Qualificações <span className="text-destructive">*</span>
949
+ </FieldLabel>
950
+ <div className="space-y-3">
951
+ {QUALIFICATION_OPTIONS.map((option) => (
952
+ <Controller
953
+ key={option.slug}
954
+ control={control}
955
+ name="qualificationSlugs"
956
+ render={({ field }) => {
957
+ const checked = field.value.includes(option.slug);
958
+ return (
959
+ <div className="flex items-start justify-between rounded-lg border p-3">
960
+ <div className="space-y-0.5">
961
+ <p className="text-sm font-medium">
962
+ {option.label}
963
+ </p>
964
+ <p className="text-xs text-muted-foreground">
965
+ {option.description}
966
+ </p>
967
+ </div>
968
+ <Switch
969
+ checked={checked}
970
+ onCheckedChange={(next) => {
971
+ if (next) {
972
+ field.onChange([
973
+ ...field.value,
974
+ option.slug,
975
+ ]);
976
+ } else {
977
+ field.onChange(
978
+ field.value.filter(
979
+ (s) => s !== option.slug
980
+ )
981
+ );
982
+ }
983
+ }}
984
+ />
985
+ </div>
986
+ );
987
+ }}
988
+ />
989
+ ))}
990
+ </div>
991
+ {errors.qualificationSlugs && (
992
+ <FieldError>
993
+ {errors.qualificationSlugs.message}
994
+ </FieldError>
995
+ )}
996
+ </Field>
997
+ </div>
398
998
  </div>
399
- {errors.qualificationSlugs && (
400
- <FieldError>{errors.qualificationSlugs.message}</FieldError>
401
- )}
402
- </Field>
403
-
404
- <SheetFooter className="mt-auto pb-4">
405
- <Button
406
- type="button"
407
- variant="outline"
408
- onClick={() => handleSheetClose(false)}
409
- disabled={saving}
410
- >
411
- Cancelar
412
- </Button>
413
- <Button type="submit" disabled={saving}>
414
- {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
415
- {isEditing ? 'Salvar alterações' : 'Criar instrutor'}
416
- </Button>
417
- </SheetFooter>
418
- </form>
999
+
1000
+ <SheetFooter className="shrink-0 border-t px-6 py-4">
1001
+ <Button
1002
+ type="button"
1003
+ variant="outline"
1004
+ onClick={() => handleSheetClose(false)}
1005
+ disabled={saving}
1006
+ >
1007
+ Cancelar
1008
+ </Button>
1009
+ <Button type="submit" disabled={saving}>
1010
+ {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
1011
+ Criar instrutor
1012
+ </Button>
1013
+ </SheetFooter>
1014
+ </form>
1015
+ )}
419
1016
  </SheetContent>
420
1017
  </Sheet>
421
1018
 
422
- <CreateLmsPersonSheet
423
- open={createPersonOpen}
424
- onOpenChange={setCreatePersonOpen}
425
- onCreated={(instructor) => {
426
- setValue('personId', instructor.personId);
427
- setCreatePersonOpen(false);
428
- }}
429
- title="Nova Pessoa"
430
- description="Preencha os dados para criar uma nova pessoa e vinculá-la como instrutora."
431
- submitLabel="Criar pessoa"
432
- successMessage="Pessoa criada com sucesso."
433
- errorMessage="Erro ao criar pessoa."
434
- defaultQualificationSlugs={['course-lessons']}
435
- />
1019
+ {editPersonOpen && personToEdit && (
1020
+ <PersonFormSheet
1021
+ open={editPersonOpen}
1022
+ person={personToEdit}
1023
+ contactTypes={contactTypes}
1024
+ documentTypes={documentTypes}
1025
+ onOpenChange={(v) => {
1026
+ setEditPersonOpen(v);
1027
+ if (!v) setPersonToEdit(null);
1028
+ }}
1029
+ onSuccess={(updated) => {
1030
+ if (updated) setSelectedPersonName(updated.name);
1031
+ void queryClient.invalidateQueries({
1032
+ queryKey: ['person-picker-autocomplete'],
1033
+ });
1034
+ void queryClient.invalidateQueries({
1035
+ queryKey: ['lms-instructors'],
1036
+ });
1037
+ setEditPersonOpen(false);
1038
+ setPersonToEdit(null);
1039
+ }}
1040
+ />
1041
+ )}
436
1042
  </>
437
1043
  );
438
1044
  }