@hed-hog/lms 0.0.357 → 0.0.358

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 (57) hide show
  1. package/dist/course/course-operations-integration.service.d.ts +31 -0
  2. package/dist/course/course-operations-integration.service.d.ts.map +1 -1
  3. package/dist/course/course-operations-integration.service.js +286 -22
  4. package/dist/course/course-operations-integration.service.js.map +1 -1
  5. package/dist/course/course-operations.controller.d.ts +10 -0
  6. package/dist/course/course-operations.controller.d.ts.map +1 -0
  7. package/dist/course/course-operations.controller.js +67 -0
  8. package/dist/course/course-operations.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +3 -1
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.service.d.ts +3 -1
  12. package/dist/course/course-structure.service.d.ts.map +1 -1
  13. package/dist/course/course-structure.service.js +13 -6
  14. package/dist/course/course-structure.service.js.map +1 -1
  15. package/dist/course/course.module.d.ts.map +1 -1
  16. package/dist/course/course.module.js +4 -1
  17. package/dist/course/course.module.js.map +1 -1
  18. package/dist/course/dto/update-course-operations-config.dto.d.ts +6 -0
  19. package/dist/course/dto/update-course-operations-config.dto.d.ts.map +1 -0
  20. package/dist/course/dto/update-course-operations-config.dto.js +33 -0
  21. package/dist/course/dto/update-course-operations-config.dto.js.map +1 -0
  22. package/dist/course/lms-operations-task.subscriber.d.ts +13 -0
  23. package/dist/course/lms-operations-task.subscriber.d.ts.map +1 -0
  24. package/dist/course/lms-operations-task.subscriber.js +57 -0
  25. package/dist/course/lms-operations-task.subscriber.js.map +1 -0
  26. package/dist/enterprise/enterprise.service.js +1 -1
  27. package/dist/enterprise/enterprise.service.js.map +1 -1
  28. package/dist/instructor/instructor.service.d.ts.map +1 -1
  29. package/dist/instructor/instructor.service.js +12 -3
  30. package/dist/instructor/instructor.service.js.map +1 -1
  31. package/hedhog/data/route.yaml +27 -0
  32. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +26 -4
  33. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +6 -0
  34. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +447 -31
  35. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +59 -47
  36. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +201 -35
  37. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +36 -5
  38. package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +382 -0
  39. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +31 -1
  40. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +91 -20
  41. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -0
  42. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +11 -3
  43. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -2
  44. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +2 -2
  45. package/hedhog/frontend/app/courses/page.tsx.ejs +21 -88
  46. package/hedhog/frontend/app/enterprise/page.tsx.ejs +18 -6
  47. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +5 -4
  48. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +16 -10
  49. package/package.json +8 -8
  50. package/src/course/course-operations-integration.service.ts +460 -22
  51. package/src/course/course-operations.controller.ts +45 -0
  52. package/src/course/course-structure.service.ts +5 -1
  53. package/src/course/course.module.ts +4 -1
  54. package/src/course/dto/update-course-operations-config.dto.ts +16 -0
  55. package/src/course/lms-operations-task.subscriber.ts +44 -0
  56. package/src/enterprise/enterprise.service.ts +1 -1
  57. package/src/instructor/instructor.service.ts +12 -3
@@ -0,0 +1,382 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { EntityPicker } from '@/components/ui/entity-picker';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Label } from '@/components/ui/label';
8
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from '@/components/ui/select';
16
+ import { Separator } from '@/components/ui/separator';
17
+ import { cn } from '@/lib/utils';
18
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
19
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
20
+ import {
21
+ AlertTriangle,
22
+ Briefcase,
23
+ Loader2,
24
+ RefreshCw,
25
+ } from 'lucide-react';
26
+ import { toast } from 'sonner';
27
+
28
+ type OperationsConfig = {
29
+ isAvailable: boolean;
30
+ projectId: number | null;
31
+ projectCode: string | null;
32
+ projectName: string | null;
33
+ taskMode: 'per_lesson' | 'per_production_status' | null;
34
+ completionStatus: string | null;
35
+ missingTasksCount: number;
36
+ };
37
+
38
+ type ProjectOption = {
39
+ id: number;
40
+ code?: string | null;
41
+ name: string;
42
+ clientName?: string | null;
43
+ status?: string;
44
+ };
45
+
46
+ type PickerOption = {
47
+ value: string;
48
+ label: string;
49
+ description?: string | null;
50
+ };
51
+
52
+ const PRODUCTION_STATUSES: { value: string; label: string }[] = [
53
+ { value: 'preparada', label: 'Preparada' },
54
+ { value: 'gravada', label: 'Gravada' },
55
+ { value: 'editada', label: 'Editada' },
56
+ { value: 'finalizada', label: 'Finalizada' },
57
+ { value: 'publicada', label: 'Publicada' },
58
+ ];
59
+
60
+ const STATUS_COLORS: Record<string, string> = {
61
+ preparada: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300',
62
+ gravada: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
63
+ editada: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
64
+ finalizada: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
65
+ publicada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
66
+ };
67
+
68
+ const PRODUCTION_STAGES = [
69
+ { stage: 'preparacao', label: 'Preparação' },
70
+ { stage: 'gravacao', label: 'Gravação' },
71
+ { stage: 'edicao', label: 'Edição' },
72
+ { stage: 'finalizacao', label: 'Finalização' },
73
+ { stage: 'publicacao', label: 'Publicação' },
74
+ ];
75
+
76
+ type Props = {
77
+ courseId: string;
78
+ };
79
+
80
+ export function CourseOperationsTab({ courseId }: Props) {
81
+ const { request } = useApp();
82
+ const queryClient = useQueryClient();
83
+
84
+ const configQueryKey = ['lms-course-operations-config', courseId];
85
+
86
+ const { data: config, isLoading: isLoadingConfig } = useQuery<OperationsConfig>({
87
+ queryKey: configQueryKey,
88
+ enabled: Boolean(courseId),
89
+ queryFn: async () => {
90
+ const res = await request<OperationsConfig>({
91
+ url: `/lms/courses/${courseId}/operations`,
92
+ method: 'GET',
93
+ });
94
+ return res.data;
95
+ },
96
+ });
97
+
98
+ const { data: projectOptionsData } = useQuery<
99
+ { data: ProjectOption[] } | ProjectOption[]
100
+ >({
101
+ queryKey: ['lms-operations-project-options-tab'],
102
+ queryFn: async () => {
103
+ try {
104
+ const res = await request<{ data: ProjectOption[] } | ProjectOption[]>({
105
+ url: '/operations/projects/options?pageSize=200&sortField=name&sortOrder=asc',
106
+ method: 'GET',
107
+ });
108
+ return res.data;
109
+ } catch {
110
+ return { data: [] };
111
+ }
112
+ },
113
+ initialData: { data: [] },
114
+ });
115
+
116
+ const projectOptions: PickerOption[] = (() => {
117
+ const raw = Array.isArray(projectOptionsData)
118
+ ? projectOptionsData
119
+ : (projectOptionsData?.data ?? []);
120
+
121
+ const current =
122
+ config?.projectId && config?.projectName
123
+ ? [
124
+ {
125
+ value: String(config.projectId),
126
+ label: config.projectName,
127
+ description: config.projectCode ?? undefined,
128
+ },
129
+ ]
130
+ : [];
131
+
132
+ const remote = raw.map((p) => ({
133
+ value: String(p.id),
134
+ label: p.name,
135
+ description: p.code ?? p.clientName ?? undefined,
136
+ }));
137
+
138
+ return [...current, ...remote].filter(
139
+ (item, idx, arr) =>
140
+ arr.findIndex((c) => c.value === item.value) === idx,
141
+ );
142
+ })();
143
+
144
+ const [projectId, setProjectId] = useState<string>('');
145
+ const [taskMode, setTaskMode] = useState<
146
+ 'per_lesson' | 'per_production_status' | ''
147
+ >('');
148
+ const [completionStatus, setCompletionStatus] = useState<string>('');
149
+
150
+ useEffect(() => {
151
+ if (!config) return;
152
+ setProjectId(config.projectId ? String(config.projectId) : '');
153
+ setTaskMode(config.taskMode ?? '');
154
+ setCompletionStatus(config.completionStatus ?? '');
155
+ }, [config]);
156
+
157
+ const { mutate: saveConfig, isPending: isSaving } = useMutation({
158
+ mutationFn: async () => {
159
+ await request({
160
+ url: `/lms/courses/${courseId}/operations`,
161
+ method: 'PATCH',
162
+ data: {
163
+ projectId: projectId ? Number(projectId) : null,
164
+ taskMode: taskMode || null,
165
+ completionStatus: completionStatus || null,
166
+ },
167
+ });
168
+ },
169
+ onSuccess: () => {
170
+ void queryClient.invalidateQueries({ queryKey: configQueryKey });
171
+ toast.success('Configuração de operações salva.');
172
+ },
173
+ onError: () => {
174
+ toast.error('Erro ao salvar configuração de operações.');
175
+ },
176
+ });
177
+
178
+ const { mutate: recreateTasks, isPending: isRecreating } = useMutation({
179
+ mutationFn: async () => {
180
+ const res = await request<{ count?: number }>({
181
+ url: `/lms/courses/${courseId}/operations/recreate-tasks`,
182
+ method: 'POST',
183
+ });
184
+ return res.data;
185
+ },
186
+ onSuccess: (data) => {
187
+ void queryClient.invalidateQueries({ queryKey: configQueryKey });
188
+ const count = typeof data === 'number' ? data : (data?.count ?? 0);
189
+ toast.success(
190
+ count > 0
191
+ ? `${count} tarefa(s) recriada(s) com sucesso.`
192
+ : 'Nenhuma tarefa faltante encontrada.',
193
+ );
194
+ },
195
+ onError: () => {
196
+ toast.error('Erro ao recriar tarefas.');
197
+ },
198
+ });
199
+
200
+ if (isLoadingConfig) {
201
+ return (
202
+ <div className="flex items-center justify-center py-10">
203
+ <Loader2 className="size-5 animate-spin text-muted-foreground" />
204
+ </div>
205
+ );
206
+ }
207
+
208
+ return (
209
+ <div className="flex flex-col gap-5 p-1">
210
+ {/* ── Projeto vinculado ──────────────────────────────────────────── */}
211
+ <div className="flex flex-col gap-1.5">
212
+ <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
213
+ Projeto Operations
214
+ </Label>
215
+ <EntityPicker<PickerOption>
216
+ value={projectId}
217
+ onChange={(val) => {
218
+ const next = String(val ?? '');
219
+ setProjectId(next);
220
+ if (!next) {
221
+ setTaskMode('');
222
+ setCompletionStatus('');
223
+ }
224
+ }}
225
+ options={projectOptions}
226
+ placeholder="Selecionar projeto..."
227
+ searchPlaceholder="Buscar projeto..."
228
+ entityLabel="projeto"
229
+ emptyStateDescription="Nenhum projeto encontrado."
230
+ getOptionValue={(o) => o.value}
231
+ getOptionLabel={(o) => o.label}
232
+ getOptionDescription={(o) => o.description ?? undefined}
233
+ />
234
+ {config?.projectName && projectId === String(config.projectId) && (
235
+ <p className="text-xs text-muted-foreground flex items-center gap-1">
236
+ <Briefcase className="size-3" />
237
+ {[config.projectCode, config.projectName].filter(Boolean).join(' — ')}
238
+ </p>
239
+ )}
240
+ </div>
241
+
242
+ <Separator />
243
+
244
+ {/* ── Configurações (habilitadas só com projeto) ──────────────────── */}
245
+ <fieldset disabled={!projectId} className="flex flex-col gap-5 disabled:opacity-50">
246
+ {/* Modo de criação de tarefas */}
247
+ <div className="flex flex-col gap-2">
248
+ <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
249
+ Modo de criação de tarefas
250
+ </Label>
251
+ <RadioGroup
252
+ value={taskMode}
253
+ onValueChange={(val) =>
254
+ setTaskMode(val as 'per_lesson' | 'per_production_status')
255
+ }
256
+ className="flex flex-col gap-2"
257
+ >
258
+ <div className="flex items-start gap-2.5 rounded-md border p-3 cursor-pointer hover:bg-muted/40 transition-colors">
259
+ <RadioGroupItem value="per_lesson" id="mode-per-lesson" className="mt-0.5" />
260
+ <div className="flex flex-col gap-0.5">
261
+ <Label htmlFor="mode-per-lesson" className="cursor-pointer text-sm font-medium">
262
+ Uma tarefa por aula
263
+ </Label>
264
+ <p className="text-xs text-muted-foreground">
265
+ Cria uma única tarefa no projeto para cada aula. Ao concluir a tarefa, o status de publicação da aula é atualizado automaticamente.
266
+ </p>
267
+ </div>
268
+ </div>
269
+
270
+ <div className="flex items-start gap-2.5 rounded-md border p-3 cursor-pointer hover:bg-muted/40 transition-colors">
271
+ <RadioGroupItem value="per_production_status" id="mode-per-status" className="mt-0.5" />
272
+ <div className="flex flex-col gap-0.5">
273
+ <Label htmlFor="mode-per-status" className="cursor-pointer text-sm font-medium">
274
+ Uma tarefa por status de produção
275
+ </Label>
276
+ <p className="text-xs text-muted-foreground">
277
+ Cria 5 tarefas por aula — uma para cada etapa de produção:
278
+ </p>
279
+ <div className="flex flex-wrap gap-1 mt-1">
280
+ {PRODUCTION_STAGES.map((s) => (
281
+ <span
282
+ key={s.stage}
283
+ className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium"
284
+ >
285
+ {s.label}
286
+ </span>
287
+ ))}
288
+ </div>
289
+ <p className="text-xs text-muted-foreground mt-1">
290
+ Ao concluir cada tarefa, o status correspondente é aplicado à aula automaticamente via tag.
291
+ </p>
292
+ </div>
293
+ </div>
294
+ </RadioGroup>
295
+ </div>
296
+
297
+ {/* Status ao concluir (apenas modo per_lesson) */}
298
+ {taskMode === 'per_lesson' && (
299
+ <div className="flex flex-col gap-1.5">
300
+ <Label
301
+ htmlFor="completion-status"
302
+ className="text-xs font-medium text-muted-foreground uppercase tracking-wide"
303
+ >
304
+ Status ao concluir a tarefa
305
+ </Label>
306
+ <Select value={completionStatus} onValueChange={setCompletionStatus}>
307
+ <SelectTrigger id="completion-status" className="w-full">
308
+ <SelectValue placeholder="Selecionar status..." />
309
+ </SelectTrigger>
310
+ <SelectContent>
311
+ {PRODUCTION_STATUSES.map((s) => (
312
+ <SelectItem key={s.value} value={s.value}>
313
+ <span
314
+ className={cn(
315
+ 'text-xs px-1.5 py-0.5 rounded',
316
+ STATUS_COLORS[s.value],
317
+ )}
318
+ >
319
+ {s.label}
320
+ </span>
321
+ </SelectItem>
322
+ ))}
323
+ </SelectContent>
324
+ </Select>
325
+ <p className="text-xs text-muted-foreground">
326
+ O status de produção desta aula será atualizado para o valor selecionado quando a tarefa for movida para "Concluído" no Operations.
327
+ </p>
328
+ </div>
329
+ )}
330
+ </fieldset>
331
+
332
+ {/* ── Tarefas faltantes ───────────────────────────────────────────── */}
333
+ {config?.missingTasksCount !== undefined && config.missingTasksCount > 0 && (
334
+ <>
335
+ <Separator />
336
+ <div className="flex flex-col gap-2 rounded-md border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3">
337
+ <div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
338
+ <AlertTriangle className="size-4 shrink-0" />
339
+ <p className="text-sm font-medium">
340
+ {config.missingTasksCount === 1
341
+ ? '1 aula sem tarefa vinculada'
342
+ : `${config.missingTasksCount} aulas sem tarefas vinculadas`}
343
+ </p>
344
+ </div>
345
+ <p className="text-xs text-amber-600 dark:text-amber-500">
346
+ As tarefas podem ter sido excluídas manualmente no Operations. Clique abaixo para recriá-las.
347
+ </p>
348
+ <Button
349
+ type="button"
350
+ variant="outline"
351
+ size="sm"
352
+ className="self-start gap-2 border-amber-300 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-400 dark:hover:bg-amber-900/40"
353
+ onClick={() => recreateTasks()}
354
+ disabled={isRecreating || !projectId}
355
+ >
356
+ {isRecreating ? (
357
+ <Loader2 className="size-3.5 animate-spin" />
358
+ ) : (
359
+ <RefreshCw className="size-3.5" />
360
+ )}
361
+ Recriar tarefas faltantes
362
+ </Button>
363
+ </div>
364
+ </>
365
+ )}
366
+
367
+ {/* ── Ações ───────────────────────────────────────────────────────── */}
368
+ <div className="flex justify-end">
369
+ <Button
370
+ type="button"
371
+ size="sm"
372
+ className="gap-2"
373
+ onClick={() => saveConfig()}
374
+ disabled={isSaving}
375
+ >
376
+ {isSaving && <Loader2 className="size-3.5 animate-spin" />}
377
+ Salvar configurações
378
+ </Button>
379
+ </div>
380
+ </div>
381
+ );
382
+ }
@@ -95,6 +95,7 @@ import {
95
95
  import { useCreateSessionMutation } from '../_data/use-course-structure-mutations';
96
96
  import { courseStructureQueryKey } from '../_data/use-course-structure-query';
97
97
  import { IconActionTooltip } from './icon-action-tooltip';
98
+ import { CourseOperationsTab } from './course-operations-tab';
98
99
  import { useStructureStore } from './store';
99
100
  import { Resource } from './types';
100
101
 
@@ -551,6 +552,25 @@ export function EditorCourse() {
551
552
  initialData: [],
552
553
  });
553
554
 
555
+ const { data: operationsConfig } = useQuery<{ isAvailable: boolean }>({
556
+ queryKey: ['lms-course-operations-config', courseId],
557
+ enabled: Boolean(courseId),
558
+ queryFn: async () => {
559
+ try {
560
+ const res = await request<{ isAvailable: boolean }>({
561
+ url: `/lms/courses/${courseId}/operations`,
562
+ method: 'GET',
563
+ });
564
+ return res.data;
565
+ } catch {
566
+ return { isAvailable: false };
567
+ }
568
+ },
569
+ initialData: { isAvailable: false },
570
+ });
571
+
572
+ const showOperationsTab = operationsConfig?.isAvailable === true;
573
+
554
574
  // ── Save mutation ───────────────────────────────────────────────────────────
555
575
  const { mutate: saveCourse, isPending: saving } = useMutation({
556
576
  mutationFn: async (data: CourseEditFormValues) => {
@@ -1559,6 +1579,7 @@ export function EditorCourse() {
1559
1579
  'recursos',
1560
1580
  'extra',
1561
1581
  ...(showVideoTab ? ['videos'] : []),
1582
+ ...(showOperationsTab ? ['operacoes'] : []),
1562
1583
  'publicacao',
1563
1584
  ] as const
1564
1585
  ).map((tab) => (
@@ -1584,7 +1605,9 @@ export function EditorCourse() {
1584
1605
  ? t('structureEditor.tabs.extra')
1585
1606
  : tab === 'videos'
1586
1607
  ? t('structureEditor.tabs.videoProfiles')
1587
- : t('structureEditor.tabs.publish')}
1608
+ : tab === 'operacoes'
1609
+ ? 'Operações'
1610
+ : t('structureEditor.tabs.publish')}
1588
1611
  </TabsTrigger>
1589
1612
  ))}
1590
1613
  </TabsList>
@@ -2271,6 +2294,13 @@ export function EditorCourse() {
2271
2294
  </TabsContent>
2272
2295
  )}
2273
2296
 
2297
+ {/* ── Tab: Operações ──────────────────────────────────────── */}
2298
+ {showOperationsTab && (
2299
+ <TabsContent value="operacoes" className="mt-0 min-w-0">
2300
+ <CourseOperationsTab courseId={courseId} />
2301
+ </TabsContent>
2302
+ )}
2303
+
2274
2304
  {/* ── Tab: Publicação ─────────────────────────────────────── */}
2275
2305
  <TabsContent
2276
2306
  value="publicacao"
@@ -160,6 +160,28 @@ function formatDateTimeLabel(value?: string | null): string | null {
160
160
  }).format(parsed);
161
161
  }
162
162
 
163
+ function getInitials(name?: string | null): string {
164
+ const normalizedName = String(name ?? '').trim();
165
+ if (!normalizedName) return '--';
166
+
167
+ return normalizedName
168
+ .split(' ')
169
+ .filter(Boolean)
170
+ .slice(0, 2)
171
+ .map((part) => part[0])
172
+ .join('')
173
+ .toUpperCase();
174
+ }
175
+
176
+ function getInstructorAvatarUrl(avatarId?: number | null): string | undefined {
177
+ if (typeof avatarId !== 'number' || avatarId <= 0) return undefined;
178
+
179
+ const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, '');
180
+ return baseUrl
181
+ ? `${baseUrl}/person/avatar/${avatarId}`
182
+ : `/person/avatar/${avatarId}`;
183
+ }
184
+
163
185
  function videoProfileResourceType(profileId: number): string {
164
186
  return `video_profile:${profileId}`;
165
187
  }
@@ -2915,13 +2937,18 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2915
2937
  control={form.control}
2916
2938
  name="published"
2917
2939
  render={({ field }) => (
2918
- <FormItem className="flex items-center justify-between gap-3 rounded-md border px-3 h-8">
2940
+ <FormItem>
2919
2941
  <FormLabel className="text-xs">Publicada</FormLabel>
2920
2942
  <FormControl>
2921
- <Switch
2922
- checked={field.value}
2923
- onCheckedChange={field.onChange}
2924
- />
2943
+ <div className="flex h-8 items-center justify-between gap-3 rounded-md border px-3">
2944
+ <p className="cursor-default text-xs text-foreground">
2945
+ Publicada
2946
+ </p>
2947
+ <Switch
2948
+ checked={field.value}
2949
+ onCheckedChange={field.onChange}
2950
+ />
2951
+ </div>
2925
2952
  </FormControl>
2926
2953
  <FormMessage className="text-xs" />
2927
2954
  </FormItem>
@@ -2940,7 +2967,11 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2940
2967
  </CardHeader>
2941
2968
  <CardContent className="px-3 pb-2 flex flex-col gap-2">
2942
2969
  {/* Picker para adicionar */}
2943
- <EntityPicker<{ id: number; name: string }>
2970
+ <EntityPicker<{
2971
+ id: number;
2972
+ name: string;
2973
+ avatarId?: number | null;
2974
+ }>
2944
2975
  key={instructorPickerResetKey}
2945
2976
  value={null}
2946
2977
  onChange={(val) => {
@@ -2962,6 +2993,44 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2962
2993
  )}
2963
2994
  getOptionValue={(o) => o.id}
2964
2995
  getOptionLabel={(o) => o.name}
2996
+ renderOption={({ option }) => {
2997
+ const avatarUrl = getInstructorAvatarUrl(
2998
+ option.avatarId
2999
+ );
3000
+ const displayName = option.name ?? String(option.id);
3001
+
3002
+ return (
3003
+ <div className="flex min-w-0 items-center gap-2">
3004
+ <Avatar className="size-6 shrink-0">
3005
+ <AvatarImage src={avatarUrl} alt={displayName} />
3006
+ <AvatarFallback className="text-[0.6rem] font-medium">
3007
+ {getInitials(displayName)}
3008
+ </AvatarFallback>
3009
+ </Avatar>
3010
+ <span className="truncate text-xs">
3011
+ {displayName}
3012
+ </span>
3013
+ </div>
3014
+ );
3015
+ }}
3016
+ renderSelectedValue={({ option, label }) => {
3017
+ const avatarUrl = getInstructorAvatarUrl(
3018
+ option?.avatarId
3019
+ );
3020
+ const displayName = option?.name ?? label;
3021
+
3022
+ return (
3023
+ <span className="flex min-w-0 items-center gap-2">
3024
+ <Avatar className="size-5 shrink-0">
3025
+ <AvatarImage src={avatarUrl} alt={displayName} />
3026
+ <AvatarFallback className="text-[0.6rem] font-medium">
3027
+ {getInitials(displayName)}
3028
+ </AvatarFallback>
3029
+ </Avatar>
3030
+ <span className="truncate">{displayName}</span>
3031
+ </span>
3032
+ );
3033
+ }}
2965
3034
  />
2966
3035
  {/* Lista de instrutores selecionados */}
2967
3036
  {selectedInstructorIds.length > 0 && (
@@ -2976,20 +3045,22 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
2976
3045
  key={sid}
2977
3046
  className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1"
2978
3047
  >
2979
- <Avatar className="size-6 shrink-0">
2980
- <AvatarImage
2981
- src={undefined}
2982
- alt={displayName}
2983
- />
2984
- <AvatarFallback className="text-[0.6rem] font-medium">
2985
- {displayName
2986
- .split(' ')
2987
- .slice(0, 2)
2988
- .map((n: string) => n[0])
2989
- .join('')
2990
- .toUpperCase()}
2991
- </AvatarFallback>
2992
- </Avatar>
3048
+ {(() => {
3049
+ const avatarUrl = getInstructorAvatarUrl(
3050
+ inst?.avatarId
3051
+ );
3052
+ return (
3053
+ <Avatar className="size-6 shrink-0">
3054
+ <AvatarImage
3055
+ src={avatarUrl}
3056
+ alt={displayName}
3057
+ />
3058
+ <AvatarFallback className="text-[0.6rem] font-medium">
3059
+ {getInitials(displayName)}
3060
+ </AvatarFallback>
3061
+ </Avatar>
3062
+ );
3063
+ })()}
2993
3064
  <span className="text-xs flex-1 truncate">
2994
3065
  {displayName}
2995
3066
  </span>
@@ -62,6 +62,7 @@ export interface LessonInstructor {
62
62
  id: string;
63
63
  role?: 'lead' | 'assistant';
64
64
  name?: string;
65
+ avatarId?: number | null;
65
66
  }
66
67
 
67
68
  export interface TranscriptionSegment {
@@ -164,7 +164,11 @@ export function normalizeSession(raw: ApiSession, index: number): Session {
164
164
  */
165
165
  export function normalizeLesson(raw: ApiLesson, order = 0): Lesson {
166
166
  const instructors: LessonInstructor[] = (raw.instrutores ?? []).map(
167
- (inst) => ({ id: String(inst.id), name: inst.name })
167
+ (inst) => ({
168
+ id: String(inst.id),
169
+ name: inst.name ?? inst.nome,
170
+ avatarId: inst.avatarId ?? null,
171
+ })
168
172
  );
169
173
 
170
174
  return {
@@ -210,7 +214,7 @@ export function normalizeStructureResponse(raw: ApiGetStructureResponse): {
210
214
  course: Course | null;
211
215
  sessions: Session[];
212
216
  lessons: Lesson[];
213
- instructors: { id: number; name: string }[];
217
+ instructors: { id: number; name: string; avatarId?: number | null }[];
214
218
  } {
215
219
  const sessions = raw.sessoes.map((s, i) => normalizeSession(s, i));
216
220
 
@@ -227,7 +231,11 @@ export function normalizeStructureResponse(raw: ApiGetStructureResponse): {
227
231
  course: raw.curso ? normalizeCourse(raw.curso) : null,
228
232
  sessions,
229
233
  lessons,
230
- instructors: raw.instructors ?? [],
234
+ instructors: (raw.instructors ?? []).map((instructor) => ({
235
+ id: Number(instructor.id),
236
+ name: instructor.name ?? instructor.nome ?? '',
237
+ avatarId: instructor.avatarId ?? null,
238
+ })),
231
239
  };
232
240
  }
233
241
 
@@ -52,8 +52,10 @@ export interface ApiCourseResource {
52
52
 
53
53
  /** Instructor linked to a lesson or available in the course pool. */
54
54
  export interface ApiLessonInstructor {
55
- id: number;
56
- name: string;
55
+ id: number | string;
56
+ name?: string;
57
+ nome?: string;
58
+ avatarId?: number | null;
57
59
  }
58
60
 
59
61
  /**
@@ -34,7 +34,7 @@ export interface CourseStructureCacheData {
34
34
  course: Course | null;
35
35
  sessions: Session[];
36
36
  lessons: Lesson[];
37
- instructors: { id: number; name: string }[];
37
+ instructors: { id: number; name: string; avatarId?: number | null }[];
38
38
  }
39
39
 
40
40
  // ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +45,7 @@ export interface CourseStructureQueryResult {
45
45
  course: Course | null;
46
46
  sessions: Session[];
47
47
  lessons: Lesson[];
48
- instructors: { id: number; name: string }[];
48
+ instructors: { id: number; name: string; avatarId?: number | null }[];
49
49
  isLoading: boolean;
50
50
  isFetching: boolean;
51
51
  isError: boolean;