@hed-hog/lms 0.0.351 → 0.0.354

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 (135) hide show
  1. package/dist/course/course-audio-transcription.service.d.ts +29 -0
  2. package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
  3. package/dist/course/course-audio-transcription.service.js +291 -0
  4. package/dist/course/course-audio-transcription.service.js.map +1 -0
  5. package/dist/course/course-lesson.controller.d.ts +10 -0
  6. package/dist/course/course-lesson.controller.d.ts.map +1 -0
  7. package/dist/course/course-lesson.controller.js +62 -0
  8. package/dist/course/course-lesson.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +41 -15
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.controller.js +50 -6
  12. package/dist/course/course-structure.controller.js.map +1 -1
  13. package/dist/course/course-structure.service.d.ts +50 -15
  14. package/dist/course/course-structure.service.d.ts.map +1 -1
  15. package/dist/course/course-structure.service.js +238 -73
  16. package/dist/course/course-structure.service.js.map +1 -1
  17. package/dist/course/course-video-conversion.service.d.ts +20 -2
  18. package/dist/course/course-video-conversion.service.d.ts.map +1 -1
  19. package/dist/course/course-video-conversion.service.js +730 -10
  20. package/dist/course/course-video-conversion.service.js.map +1 -1
  21. package/dist/course/course.controller.d.ts +24 -8
  22. package/dist/course/course.controller.d.ts.map +1 -1
  23. package/dist/course/course.module.d.ts.map +1 -1
  24. package/dist/course/course.module.js +5 -3
  25. package/dist/course/course.module.js.map +1 -1
  26. package/dist/course/course.service.d.ts +24 -8
  27. package/dist/course/course.service.d.ts.map +1 -1
  28. package/dist/course/course.service.js +112 -176
  29. package/dist/course/course.service.js.map +1 -1
  30. package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
  31. package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
  32. package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
  33. package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
  34. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
  35. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  36. package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
  37. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  38. package/dist/course/dto/create-course.dto.d.ts +1 -1
  39. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  40. package/dist/course/dto/create-course.dto.js +6 -6
  41. package/dist/course/dto/create-course.dto.js.map +1 -1
  42. package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
  43. package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
  44. package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
  45. package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
  46. package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
  47. package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
  48. package/dist/course/dto/update-course-resources.dto.js +10 -3
  49. package/dist/course/dto/update-course-resources.dto.js.map +1 -1
  50. package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
  51. package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
  52. package/dist/course/dto/update-transcription-segments.dto.js +38 -0
  53. package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
  54. package/dist/course/lms-setting.controller.d.ts +13 -0
  55. package/dist/course/lms-setting.controller.d.ts.map +1 -0
  56. package/dist/course/lms-setting.controller.js +53 -0
  57. package/dist/course/lms-setting.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  59. package/dist/enterprise/training/training-admin.service.js +74 -33
  60. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/lms.module.d.ts.map +1 -1
  66. package/dist/lms.module.js +6 -0
  67. package/dist/lms.module.js.map +1 -1
  68. package/hedhog/data/route.yaml +63 -0
  69. package/hedhog/data/setting_group.yaml +76 -0
  70. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
  71. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
  72. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
  73. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
  74. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
  75. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
  76. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
  77. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
  78. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
  79. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  80. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
  97. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
  98. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
  99. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
  100. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
  101. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
  102. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
  104. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
  105. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
  106. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
  107. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
  108. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
  110. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
  111. package/hedhog/frontend/messages/en.json +39 -3
  112. package/hedhog/frontend/messages/pt.json +39 -3
  113. package/hedhog/table/course.yaml +8 -0
  114. package/hedhog/table/course_lesson_file.yaml +12 -4
  115. package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
  116. package/hedhog/table/course_lesson_video_frame.yaml +25 -0
  117. package/package.json +9 -9
  118. package/src/course/course-audio-transcription.service.ts +393 -0
  119. package/src/course/course-lesson.controller.ts +28 -0
  120. package/src/course/course-structure.controller.ts +49 -3
  121. package/src/course/course-structure.service.ts +294 -32
  122. package/src/course/course-video-conversion.service.ts +972 -6
  123. package/src/course/course.module.ts +5 -3
  124. package/src/course/course.service.ts +87 -139
  125. package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
  126. package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
  127. package/src/course/dto/create-course.dto.ts +5 -5
  128. package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
  129. package/src/course/dto/update-course-resources.dto.ts +18 -3
  130. package/src/course/dto/update-transcription-segments.dto.ts +20 -0
  131. package/src/course/lms-setting.controller.ts +30 -0
  132. package/src/enterprise/training/training-admin.service.ts +77 -24
  133. package/src/index.ts +2 -0
  134. package/src/lms.module.ts +6 -0
  135. package/hedhog/table/course_instructor.yaml +0 -27
@@ -10,9 +10,6 @@ import {
10
10
  CircleDot,
11
11
  Clock,
12
12
  Download,
13
- File as FileIcon,
14
- FileImage,
15
- FileText,
16
13
  Layers,
17
14
  Loader2,
18
15
  Pencil,
@@ -32,6 +29,7 @@ import { z } from 'zod';
32
29
 
33
30
  import { createDefaultTemplate } from '@/app/(app)/(libraries)/lms/_lib/editor/types';
34
31
  import { FfmpegParamsEditor } from '@/components/ffmpeg-params-editor';
32
+ import { FileTypeIcon } from '@/components/file-type-icon';
35
33
  import { RichTextEditor } from '@/components/rich-text-editor';
36
34
  import { Badge } from '@/components/ui/badge';
37
35
  import { Button } from '@/components/ui/button';
@@ -54,6 +52,7 @@ import {
54
52
  FormMessage,
55
53
  } from '@/components/ui/form';
56
54
  import { Input } from '@/components/ui/input';
55
+ import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
57
56
  import { Separator } from '@/components/ui/separator';
58
57
  import {
59
58
  Sheet,
@@ -62,7 +61,6 @@ import {
62
61
  SheetHeader,
63
62
  SheetTitle,
64
63
  } from '@/components/ui/sheet';
65
- import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
66
64
  import { Skeleton } from '@/components/ui/skeleton';
67
65
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
68
66
  import {
@@ -76,7 +74,6 @@ import { cn } from '@/lib/utils';
76
74
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
77
75
  import { useMutation, useQueryClient } from '@tanstack/react-query';
78
76
 
79
- import { InstructorFormSheet } from '../../../../instructors/_components/instructor-form-sheet';
80
77
  import type {
81
78
  CourseEditFormValues,
82
79
  PickerOption,
@@ -86,6 +83,7 @@ import { CourseClassificationCard } from '../../_components/CourseClassification
86
83
  import { CourseContentCard } from '../../_components/CourseContentCard';
87
84
  import { CourseDangerZoneCard } from '../../_components/CourseDangerZoneCard';
88
85
  import { CourseFlagsCard } from '../../_components/CourseFlagsCard';
86
+ import { CourseInstructorsSummaryCard } from './CourseInstructorsSummaryCard';
89
87
  import { CourseMediaCard } from '../../_components/CourseMediaCard';
90
88
  import { CourseRelationsCard } from '../../_components/CourseRelationsCard';
91
89
  import {
@@ -96,6 +94,7 @@ import {
96
94
  } from '../_data/services/course-structure.service';
97
95
  import { useCreateSessionMutation } from '../_data/use-course-structure-mutations';
98
96
  import { courseStructureQueryKey } from '../_data/use-course-structure-query';
97
+ import { IconActionTooltip } from './icon-action-tooltip';
99
98
  import { useStructureStore } from './store';
100
99
  import { Resource } from './types';
101
100
 
@@ -108,6 +107,7 @@ type ApiCourseDetail = {
108
107
  code?: string | null;
109
108
  title: string;
110
109
  description: string;
110
+ locale_id?: number | null;
111
111
  primaryColor?: string | null;
112
112
  secondaryColor?: string | null;
113
113
  level: 'beginner' | 'intermediate' | 'advanced';
@@ -125,7 +125,6 @@ type ApiCourseDetail = {
125
125
  sessionCount: number;
126
126
  averageCompletion: number;
127
127
  certificatesIssued: number;
128
- instructorIds?: number[];
129
128
  instructors?: Array<{ id: number; name: string; avatarId: number | null }>;
130
129
  logoFileId?: number | null;
131
130
  logoFilename?: string | null;
@@ -173,19 +172,6 @@ type ApiCategoryList = {
173
172
  page: number;
174
173
  pageSize: number;
175
174
  };
176
- type ApiInstructor = {
177
- id: number;
178
- personId: number;
179
- name: string;
180
- avatarId?: number | null;
181
- qualificationSlugs: string[];
182
- };
183
- type ApiInstructorList = {
184
- data: ApiInstructor[];
185
- total: number;
186
- page: number;
187
- pageSize: number;
188
- };
189
175
  type ApiCertificateTemplate = {
190
176
  id: number;
191
177
  name: string;
@@ -213,14 +199,21 @@ type CourseResourceApi = {
213
199
  id: number;
214
200
  nome: string;
215
201
  fileId?: number | null;
216
- tipo?: string | null;
217
- publico?: boolean;
202
+ type?: string | null;
203
+ is_public?: boolean;
218
204
  };
219
205
 
220
206
  type CourseResourceItem = Resource & {
221
207
  fileId?: number | null;
222
208
  };
223
209
 
210
+ const COURSE_RESOURCE_ALLOWED_TYPES = new Set([
211
+ 'lesson_audio',
212
+ 'student_download',
213
+ 'supplementary_material',
214
+ 'video_original',
215
+ ]);
216
+
224
217
  type Locale = { id?: number; code: string; name: string };
225
218
 
226
219
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -309,24 +302,6 @@ function slugify(value: string) {
309
302
  .replace(/^-+|-+$/g, '');
310
303
  }
311
304
 
312
- function getInstructorAvatarUrl(avatarId?: number | null) {
313
- return typeof avatarId === 'number' && avatarId > 0
314
- ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
315
- : null;
316
- }
317
-
318
- function getResourceIcon(type: string): typeof FileIcon {
319
- if (type === 'application/pdf' || type.endsWith('pdf')) return FileText;
320
- if (type.startsWith('image/')) return FileImage;
321
- return FileIcon;
322
- }
323
-
324
- function getResourceIconColor(type: string): string {
325
- if (type === 'application/pdf' || type.endsWith('pdf')) return 'text-red-500';
326
- if (type.startsWith('image/')) return 'text-blue-500';
327
- return 'text-muted-foreground';
328
- }
329
-
330
305
  function formatFileSize(bytes: number): string {
331
306
  if (bytes < 1024) return `${bytes} B`;
332
307
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -341,11 +316,19 @@ function mapApiCourseResourceToLocal(
341
316
  fileId: item.fileId ?? undefined,
342
317
  name: item.nome,
343
318
  size: '',
344
- type: item.tipo ?? 'file',
345
- public: item.publico ?? true,
319
+ type: item.type ?? 'student_download',
320
+ public: item.is_public ?? true,
346
321
  };
347
322
  }
348
323
 
324
+ function normalizeCourseResourceType(type?: string | null) {
325
+ const value = String(type ?? '').trim();
326
+ if (!value) return undefined;
327
+ if (COURSE_RESOURCE_ALLOWED_TYPES.has(value)) return value;
328
+ if (/^video_profile:\d+$/.test(value)) return value;
329
+ return 'student_download';
330
+ }
331
+
349
332
  // ── Schema ────────────────────────────────────────────────────────────────────
350
333
 
351
334
  function buildSchema(t: (key: string) => string) {
@@ -369,6 +352,7 @@ function buildSchema(t: (key: string) => string) {
369
352
  nivel: z.enum(['iniciante', 'intermediario', 'avancado']),
370
353
  status: z.enum(['ativo', 'rascunho', 'arquivado']),
371
354
  tipoOferta: z.enum(['agendado', 'sob_demanda', 'hibrido']),
355
+ localeId: z.string().optional().default(''),
372
356
  categorias: z.array(z.string()).optional().default([]),
373
357
  operationsProjectId: z.string().optional(),
374
358
  primaryColor: z
@@ -397,6 +381,9 @@ export function EditorCourse() {
397
381
 
398
382
  const { request, currentLocaleCode, locales } = useApp();
399
383
  const t = useTranslations('lms.CursoEditPage');
384
+ const courseLocaleLabel = currentLocaleCode?.startsWith('en')
385
+ ? 'Course Language'
386
+ : 'Idioma do Curso';
400
387
  const router = useRouter();
401
388
  const isMobile = useIsMobile();
402
389
  const queryClient = useQueryClient();
@@ -404,10 +391,6 @@ export function EditorCourse() {
404
391
 
405
392
  // ── UI state ────────────────────────────────────────────────────────────────
406
393
  const [activeTab, setActiveTab] = useState('estrutura');
407
- const [instructorEditSheetOpen, setInstructorEditSheetOpen] = useState(false);
408
- const [editingInstructorId, setEditingInstructorId] = useState<number | null>(
409
- null
410
- );
411
394
  const [categoryEditSheetOpen, setCategoryEditSheetOpen] = useState(false);
412
395
  const [editingCategoryId, setEditingCategoryId] = useState<number | null>(
413
396
  null
@@ -442,6 +425,8 @@ export function EditorCourse() {
442
425
  const [courseResources, setCourseResources] = useState<CourseResourceItem[]>(
443
426
  []
444
427
  );
428
+ const [downloadingCourseResourceKeys, setDownloadingCourseResourceKeys] =
429
+ useState<Set<string>>(() => new Set<string>());
445
430
  const [resourcesDragOver, setResourcesDragOver] = useState(false);
446
431
  const [isUploadingResources, setIsUploadingResources] = useState(false);
447
432
  const [isSavingResources, setIsSavingResources] = useState(false);
@@ -496,32 +481,6 @@ export function EditorCourse() {
496
481
  initialData: { data: [], total: 0, page: 1, pageSize: 500 },
497
482
  });
498
483
 
499
- const { data: instructorListData, refetch: refetchInstructorOptions } =
500
- useQuery<ApiInstructorList>({
501
- queryKey: ['lms-course-edit-instructors'],
502
- queryFn: async () => {
503
- const response = await request<ApiInstructorList | ApiInstructor[]>({
504
- url: '/lms/instructors',
505
- method: 'GET',
506
- params: {
507
- page: 1,
508
- pageSize: 500,
509
- qualificationSlugs: ['course-lessons'],
510
- },
511
- });
512
- const payload = response.data;
513
- if (Array.isArray(payload))
514
- return {
515
- data: payload,
516
- total: payload.length,
517
- page: 1,
518
- pageSize: payload.length,
519
- };
520
- return payload;
521
- },
522
- initialData: { data: [], total: 0, page: 1, pageSize: 500 },
523
- });
524
-
525
484
  const {
526
485
  data: certificateTemplateData,
527
486
  refetch: refetchCertificateTemplates,
@@ -597,6 +556,7 @@ export function EditorCourse() {
597
556
  code: data.code?.trim() || undefined,
598
557
  title: data.tituloComercial.trim(),
599
558
  description: data.descricaoPublica.trim(),
559
+ locale_id: data.localeId ? Number(data.localeId) : null,
600
560
  requirements: data.preRequisitos?.trim() || undefined,
601
561
  objectives: data.objetivos?.trim() || undefined,
602
562
  targetAudience: data.publicoAlvo?.trim() || undefined,
@@ -612,7 +572,6 @@ export function EditorCourse() {
612
572
  isFeatured: data.destaque,
613
573
  isListed: data.listado,
614
574
  certificateModel: data.modeloCertificado.trim() || null,
615
- instructorIds: (data.instrutores ?? []).map(Number),
616
575
  operationsProjectId: data.operationsProjectId
617
576
  ? Number(data.operationsProjectId)
618
577
  : null,
@@ -632,6 +591,7 @@ export function EditorCourse() {
632
591
  }
633
592
  setPersistedCertificateModel(data.modeloCertificado || '');
634
593
  updateCourseInStore({
594
+ code: data.code,
635
595
  title: data.tituloComercial,
636
596
  slug: data.slug,
637
597
  name: data.nomeInterno,
@@ -641,6 +601,9 @@ export function EditorCourse() {
641
601
  void queryClient.invalidateQueries({
642
602
  queryKey: courseStructureQueryKey(courseId),
643
603
  });
604
+ void queryClient.invalidateQueries({
605
+ queryKey: ['lms-course-detail', courseId],
606
+ });
644
607
  form.reset(data);
645
608
  toast.success(t('toasts.courseUpdated'));
646
609
  },
@@ -665,11 +628,11 @@ export function EditorCourse() {
665
628
  nivel: 'iniciante',
666
629
  status: 'rascunho',
667
630
  tipoOferta: 'sob_demanda',
631
+ localeId: '',
668
632
  categorias: [],
669
633
  operationsProjectId: '',
670
634
  primaryColor: '#1D4ED8',
671
635
  secondaryColor: '#111827',
672
- instrutores: [],
673
636
  preRequisitos: '',
674
637
  modeloCertificado: '',
675
638
  certificado: false,
@@ -757,24 +720,6 @@ export function EditorCourse() {
757
720
  );
758
721
  }, [apiCourse, operationsProjectOptionsData]);
759
722
 
760
- const instructorOptions = useMemo(() => {
761
- const fromCourse = (apiCourse?.instructors ?? []).map((item) => ({
762
- value: String(item.id),
763
- label: item.name,
764
- avatarUrl: getInstructorAvatarUrl(item.avatarId),
765
- meta: `ID ${item.id}`,
766
- }));
767
- const fromDirectory = (instructorListData?.data ?? []).map((item) => ({
768
- value: String(item.id),
769
- label: item.name,
770
- avatarUrl: getInstructorAvatarUrl(item.avatarId),
771
- meta: item.qualificationSlugs?.join(' • ') || `ID ${item.id}`,
772
- }));
773
- return [...fromCourse, ...fromDirectory].filter(
774
- (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
775
- );
776
- }, [apiCourse?.instructors, instructorListData]);
777
-
778
723
  const certificateOptions = useMemo(() => {
779
724
  const serverOptions = (certificateTemplateData?.data ?? []).map((item) => ({
780
725
  value: item.slug || String(item.id),
@@ -881,13 +826,13 @@ export function EditorCourse() {
881
826
  nivel: toPtLevel(apiCourse.level),
882
827
  status: toPtStatus(apiCourse.status),
883
828
  tipoOferta: toPtOfferingType(apiCourse.offeringType),
829
+ localeId: apiCourse.locale_id ? String(apiCourse.locale_id) : '',
884
830
  categorias: apiCourse.categories ?? [],
885
831
  operationsProjectId: apiCourse.operationsProjectId
886
832
  ? String(apiCourse.operationsProjectId)
887
833
  : '',
888
834
  primaryColor: apiCourse.primaryColor || '#1D4ED8',
889
835
  secondaryColor: apiCourse.secondaryColor || '#111827',
890
- instrutores: (apiCourse.instructorIds ?? []).map(String),
891
836
  preRequisitos: apiCourse.requirements ?? '',
892
837
  modeloCertificado: nextCertificateModel,
893
838
  certificado: apiCourse.hasCertificate ?? false,
@@ -969,14 +914,18 @@ export function EditorCourse() {
969
914
  setIsSavingResources(true);
970
915
  try {
971
916
  const saved = await updateCourseResources(request, courseId, {
972
- recursos: nextResources.map((item) => ({
973
- nome: item.name,
974
- ...(typeof item.fileId === 'number' && item.fileId > 0
975
- ? { fileId: item.fileId }
976
- : {}),
977
- ...(item.type ? { tipo: item.type } : {}),
978
- publico: item.public,
979
- })),
917
+ recursos: nextResources.map((item) => {
918
+ const normalizedType = normalizeCourseResourceType(item.type);
919
+
920
+ return {
921
+ nome: item.name,
922
+ ...(typeof item.fileId === 'number' && item.fileId > 0
923
+ ? { fileId: item.fileId }
924
+ : {}),
925
+ ...(normalizedType ? { type: normalizedType } : {}),
926
+ is_public: item.public,
927
+ };
928
+ }),
980
929
  });
981
930
 
982
931
  setCourseResources(saved.map(mapApiCourseResourceToLocal));
@@ -1006,7 +955,7 @@ export function EditorCourse() {
1006
955
  fileId: uploaded.id,
1007
956
  name: file.name,
1008
957
  size: formatFileSize(file.size),
1009
- type: file.type || file.name.split('.').pop() || 'file',
958
+ type: 'student_download',
1010
959
  public: true,
1011
960
  })
1012
961
  )
@@ -1060,9 +1009,16 @@ export function EditorCourse() {
1060
1009
  return;
1061
1010
  }
1062
1011
 
1012
+ const downloadKey = String(item.fileId ?? item.id);
1013
+ setDownloadingCourseResourceKeys((current) => {
1014
+ const next = new Set(current);
1015
+ next.add(downloadKey);
1016
+ return next;
1017
+ });
1018
+
1063
1019
  try {
1064
1020
  const response = await request<{ url?: string }>({
1065
- url: `/file/open/${item.fileId}`,
1021
+ url: `/file/download/${item.fileId}`,
1066
1022
  method: 'PUT',
1067
1023
  });
1068
1024
  const url = response?.data?.url;
@@ -1070,9 +1026,39 @@ export function EditorCourse() {
1070
1026
  toast.error('Nao foi possivel gerar o link de download.');
1071
1027
  return;
1072
1028
  }
1073
- window.open(url, '_blank', 'noopener,noreferrer');
1029
+
1030
+ try {
1031
+ const downloadResponse = await fetch(url);
1032
+ if (!downloadResponse.ok) {
1033
+ throw new Error('download request failed');
1034
+ }
1035
+
1036
+ const blob = await downloadResponse.blob();
1037
+ const objectUrl = window.URL.createObjectURL(blob);
1038
+ const anchor = document.createElement('a');
1039
+ anchor.href = objectUrl;
1040
+ anchor.download = item.name || `arquivo-${item.fileId}`;
1041
+ anchor.rel = 'noopener noreferrer';
1042
+ document.body.appendChild(anchor);
1043
+ anchor.click();
1044
+ anchor.remove();
1045
+ window.URL.revokeObjectURL(objectUrl);
1046
+ } catch {
1047
+ const anchor = document.createElement('a');
1048
+ anchor.href = url;
1049
+ anchor.rel = 'noopener noreferrer';
1050
+ document.body.appendChild(anchor);
1051
+ anchor.click();
1052
+ anchor.remove();
1053
+ }
1074
1054
  } catch {
1075
1055
  toast.error('Nao foi possivel baixar o recurso.');
1056
+ } finally {
1057
+ setDownloadingCourseResourceKeys((current) => {
1058
+ const next = new Set(current);
1059
+ next.delete(downloadKey);
1060
+ return next;
1061
+ });
1076
1062
  }
1077
1063
  }
1078
1064
 
@@ -1112,7 +1098,11 @@ export function EditorCourse() {
1112
1098
  }
1113
1099
  const objectUrl = URL.createObjectURL(file);
1114
1100
  setter(objectUrl);
1115
- type === 'logo' ? setUploadingLogo(true) : setUploadingBanner(true);
1101
+ if (type === 'logo') {
1102
+ setUploadingLogo(true);
1103
+ } else {
1104
+ setUploadingBanner(true);
1105
+ }
1116
1106
  try {
1117
1107
  const formData = new FormData();
1118
1108
  formData.append('file', file);
@@ -1136,7 +1126,11 @@ export function EditorCourse() {
1136
1126
  } catch {
1137
1127
  toast.error(t('toasts.fileUploadError'));
1138
1128
  } finally {
1139
- type === 'logo' ? setUploadingLogo(false) : setUploadingBanner(false);
1129
+ if (type === 'logo') {
1130
+ setUploadingLogo(false);
1131
+ } else {
1132
+ setUploadingBanner(false);
1133
+ }
1140
1134
  }
1141
1135
  }
1142
1136
 
@@ -1235,21 +1229,6 @@ export function EditorCourse() {
1235
1229
  }
1236
1230
  }
1237
1231
 
1238
- function handleCreateInstructor() {
1239
- setEditingInstructorId(null);
1240
- setInstructorEditSheetOpen(true);
1241
- }
1242
-
1243
- function handleEditInstructor(instructorId: string) {
1244
- const parsed = Number(instructorId);
1245
- if (!Number.isFinite(parsed) || parsed <= 0) {
1246
- toast.error('Instrutor inválido para edição.');
1247
- return;
1248
- }
1249
- setEditingInstructorId(parsed);
1250
- setInstructorEditSheetOpen(true);
1251
- }
1252
-
1253
1232
  function handleEditVideoProfile(profileId: number) {
1254
1233
  const profile = allVideoProfiles.find((p) => p.id === profileId);
1255
1234
  if (!profile) return;
@@ -1390,7 +1369,10 @@ export function EditorCourse() {
1390
1369
  if (data.status === 'ativo') {
1391
1370
  const requiredProfileIds = new Set(linkedProfileIds);
1392
1371
  const invalidLesson = lessons.find((lesson) => {
1393
- if (lesson.type !== 'video' || lesson.videoProvider !== 'file_storage') {
1372
+ if (
1373
+ lesson.type !== 'video' ||
1374
+ lesson.videoProvider !== 'file_storage'
1375
+ ) {
1394
1376
  return false;
1395
1377
  }
1396
1378
 
@@ -1674,6 +1656,120 @@ export function EditorCourse() {
1674
1656
  )}
1675
1657
  />
1676
1658
  </div>
1659
+
1660
+ <FormField
1661
+ control={form.control}
1662
+ name="localeId"
1663
+ render={({ field }) => (
1664
+ <FormItem>
1665
+ <FormLabel className="text-xs">
1666
+ {courseLocaleLabel}
1667
+ </FormLabel>
1668
+ <FormControl>
1669
+ <EntityPicker
1670
+ value={field.value ? Number(field.value) : null}
1671
+ onChange={(value) => {
1672
+ form.setValue(
1673
+ 'localeId',
1674
+ value ? String(value) : '',
1675
+ {
1676
+ shouldDirty: true,
1677
+ shouldTouch: true,
1678
+ shouldValidate: true,
1679
+ }
1680
+ );
1681
+ }}
1682
+ valueType="number"
1683
+ placeholder="Selecionar idioma..."
1684
+ searchable
1685
+ showCreateButton
1686
+ createTitle="Novo Idioma"
1687
+ createFields={[
1688
+ {
1689
+ name: 'name',
1690
+ label: 'Nome',
1691
+ type: 'text',
1692
+ required: true,
1693
+ },
1694
+ {
1695
+ name: 'code',
1696
+ label: 'Código (2 letras)',
1697
+ type: 'text',
1698
+ required: true,
1699
+ },
1700
+ {
1701
+ name: 'region',
1702
+ label: 'Região (2 letras)',
1703
+ type: 'text',
1704
+ required: false,
1705
+ },
1706
+ ]}
1707
+ loadOptions={async ({ page, search }) => {
1708
+ try {
1709
+ const res = await request<
1710
+ | {
1711
+ data?: Array<{
1712
+ id: number;
1713
+ name: string;
1714
+ code: string;
1715
+ region?: string;
1716
+ }>;
1717
+ }
1718
+ | Array<{
1719
+ id: number;
1720
+ name: string;
1721
+ code: string;
1722
+ region?: string;
1723
+ }>
1724
+ >({
1725
+ url: `/core/locales?page=${page}&search=${encodeURIComponent(search ?? '')}`,
1726
+ method: 'GET',
1727
+ });
1728
+
1729
+ const payload = Array.isArray(res.data)
1730
+ ? res.data
1731
+ : (res.data?.data ?? []);
1732
+
1733
+ return {
1734
+ items: payload,
1735
+ hasMore: false,
1736
+ };
1737
+ } catch {
1738
+ return {
1739
+ items: [],
1740
+ hasMore: false,
1741
+ };
1742
+ }
1743
+ }}
1744
+ getOptionValue={(option) => option.id}
1745
+ getOptionLabel={(option) =>
1746
+ `${option.name} (${option.code})`
1747
+ }
1748
+ onCreate={async (data) => {
1749
+ const res = await request<{
1750
+ id: number;
1751
+ name: string;
1752
+ code: string;
1753
+ region?: string;
1754
+ }>({
1755
+ url: '/core/locales',
1756
+ method: 'POST',
1757
+ data,
1758
+ });
1759
+
1760
+ return {
1761
+ id: res.data.id,
1762
+ name: res.data.name,
1763
+ code: res.data.code,
1764
+ region: res.data.region,
1765
+ };
1766
+ }}
1767
+ />
1768
+ </FormControl>
1769
+ <FormMessage className="text-xs" />
1770
+ </FormItem>
1771
+ )}
1772
+ />
1677
1773
  </CardContent>
1678
1774
  </Card>
1679
1775
 
@@ -1715,19 +1811,22 @@ export function EditorCourse() {
1715
1811
  levels={NIVEIS}
1716
1812
  statuses={STATUS_OPTIONS}
1717
1813
  offeringTypes={OFFERING_TYPE_OPTIONS}
1814
+ compact
1718
1815
  />
1719
1816
  <CourseRelationsCard
1720
1817
  form={form}
1721
1818
  t={t}
1722
1819
  categoryOptions={categoryOptions}
1723
1820
  projectOptions={projectOptions}
1724
- instructorOptions={instructorOptions}
1725
1821
  onCreateCategory={handleCreateCategory}
1726
- onCreateInstructor={handleCreateInstructor}
1727
1822
  onEditCategory={handleEditCategory}
1728
- onEditInstructor={handleEditInstructor}
1823
+ compact
1824
+ />
1825
+ <CourseInstructorsSummaryCard
1826
+ instructors={apiCourse?.instructors ?? []}
1827
+ compact
1729
1828
  />
1730
- <CourseContentCard form={form} />
1829
+ <CourseContentCard form={form} compact />
1731
1830
  </TabsContent>
1732
1831
 
1733
1832
  {/* ── Tab: Mídia ──────────────────────────────────────────── */}
@@ -1780,6 +1879,7 @@ export function EditorCourse() {
1780
1879
  logoImageType={apiCourse?.logoImageType}
1781
1880
  bannerImageType={apiCourse?.bannerImageType}
1782
1881
  t={t}
1882
+ compact
1783
1883
  />
1784
1884
  </TabsContent>
1785
1885
 
@@ -1893,17 +1993,20 @@ export function EditorCourse() {
1893
1993
  ) : (
1894
1994
  <div className="flex flex-col gap-1">
1895
1995
  {courseResources.map((item) => {
1896
- const ItemIcon = getResourceIcon(item.type);
1996
+ const isDownloadingResource =
1997
+ downloadingCourseResourceKeys.has(
1998
+ String(item.fileId ?? item.id)
1999
+ );
2000
+
1897
2001
  return (
1898
2002
  <div
1899
2003
  key={item.id}
1900
2004
  className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2"
1901
2005
  >
1902
- <ItemIcon
1903
- className={cn(
1904
- 'size-3.5 shrink-0',
1905
- getResourceIconColor(item.type)
1906
- )}
2006
+ <FileTypeIcon
2007
+ filename={item.name}
2008
+ mimeType={item.type}
2009
+ size={14}
1907
2010
  />
1908
2011
  <div className="flex-1 min-w-0">
1909
2012
  <p className="text-xs truncate font-medium">
@@ -1914,36 +2017,56 @@ export function EditorCourse() {
1914
2017
  t('structureEditor.resources.attachedFile')}
1915
2018
  </p>
1916
2019
  </div>
1917
- <Button
1918
- type="button"
1919
- variant="ghost"
1920
- size="icon"
1921
- className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
1922
- onClick={() =>
1923
- void handleDownloadCourseResource(item)
1924
- }
1925
- aria-label={t(
2020
+ <IconActionTooltip
2021
+ label={t(
1926
2022
  'structureEditor.resources.downloadAria',
1927
2023
  { name: item.name }
1928
2024
  )}
2025
+ asWrapper={isDownloadingResource}
1929
2026
  >
1930
- <Download className="size-3" />
1931
- </Button>
1932
- <Button
1933
- type="button"
1934
- variant="ghost"
1935
- size="icon"
1936
- className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
1937
- onClick={() =>
1938
- void handleRemoveCourseResource(item)
1939
- }
1940
- aria-label={t(
2027
+ <Button
2028
+ type="button"
2029
+ variant="ghost"
2030
+ size="icon"
2031
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
2032
+ disabled={isDownloadingResource}
2033
+ onClick={() =>
2034
+ void handleDownloadCourseResource(item)
2035
+ }
2036
+ aria-label={t(
2037
+ 'structureEditor.resources.downloadAria',
2038
+ { name: item.name }
2039
+ )}
2040
+ >
2041
+ {isDownloadingResource ? (
2042
+ <Loader2 className="size-3 animate-spin" />
2043
+ ) : (
2044
+ <Download className="size-3" />
2045
+ )}
2046
+ </Button>
2047
+ </IconActionTooltip>
2048
+ <IconActionTooltip
2049
+ label={t(
1941
2050
  'structureEditor.resources.removeAria',
1942
2051
  { name: item.name }
1943
2052
  )}
1944
2053
  >
1945
- <X className="size-3" />
1946
- </Button>
2054
+ <Button
2055
+ type="button"
2056
+ variant="ghost"
2057
+ size="icon"
2058
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
2059
+ onClick={() =>
2060
+ void handleRemoveCourseResource(item)
2061
+ }
2062
+ aria-label={t(
2063
+ 'structureEditor.resources.removeAria',
2064
+ { name: item.name }
2065
+ )}
2066
+ >
2067
+ <X className="size-3" />
2068
+ </Button>
2069
+ </IconActionTooltip>
1947
2070
  </div>
1948
2071
  );
1949
2072
  })}
@@ -1963,11 +2086,13 @@ export function EditorCourse() {
1963
2086
  t={t}
1964
2087
  options={certificateOptions}
1965
2088
  onCreateTemplate={handleCreateCertificateTemplate}
2089
+ compact
1966
2090
  />
1967
- <CourseFlagsCard form={form} t={t} />
2091
+ <CourseFlagsCard form={form} t={t} compact />
1968
2092
  <CourseDangerZoneCard
1969
2093
  t={t}
1970
2094
  onDelete={() => setDeleteDialogOpen(true)}
2095
+ compact
1971
2096
  />
1972
2097
  </TabsContent>
1973
2098
 
@@ -2087,36 +2212,48 @@ export function EditorCourse() {
2087
2212
  )}
2088
2213
  </p>
2089
2214
  </div>
2090
- <Button
2091
- type="button"
2092
- variant="ghost"
2093
- size="icon"
2094
- className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
2095
- onClick={() =>
2096
- handleEditVideoProfile(profileId)
2097
- }
2098
- aria-label={t(
2215
+ <IconActionTooltip
2216
+ label={t(
2099
2217
  'structureEditor.videoProfiles.editAria'
2100
2218
  )}
2101
2219
  >
2102
- <Pencil className="size-3" />
2103
- </Button>
2104
- <Button
2105
- type="button"
2106
- variant="ghost"
2107
- size="icon"
2108
- className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
2109
- onClick={() =>
2110
- setLinkedProfileIds((prev) =>
2111
- prev.filter((id) => id !== profileId)
2112
- )
2113
- }
2114
- aria-label={t(
2220
+ <Button
2221
+ type="button"
2222
+ variant="ghost"
2223
+ size="icon"
2224
+ className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
2225
+ onClick={() =>
2226
+ handleEditVideoProfile(profileId)
2227
+ }
2228
+ aria-label={t(
2229
+ 'structureEditor.videoProfiles.editAria'
2230
+ )}
2231
+ >
2232
+ <Pencil className="size-3" />
2233
+ </Button>
2234
+ </IconActionTooltip>
2235
+ <IconActionTooltip
2236
+ label={t(
2115
2237
  'structureEditor.videoProfiles.removeAria'
2116
2238
  )}
2117
2239
  >
2118
- <X className="size-3" />
2119
- </Button>
2240
+ <Button
2241
+ type="button"
2242
+ variant="ghost"
2243
+ size="icon"
2244
+ className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
2245
+ onClick={() =>
2246
+ setLinkedProfileIds((prev) =>
2247
+ prev.filter((id) => id !== profileId)
2248
+ )
2249
+ }
2250
+ aria-label={t(
2251
+ 'structureEditor.videoProfiles.removeAria'
2252
+ )}
2253
+ >
2254
+ <X className="size-3" />
2255
+ </Button>
2256
+ </IconActionTooltip>
2120
2257
  </div>
2121
2258
  );
2122
2259
  })}
@@ -2245,33 +2382,6 @@ export function EditorCourse() {
2245
2382
  </div>
2246
2383
  </form>
2247
2384
 
2248
- <InstructorFormSheet
2249
- open={instructorEditSheetOpen}
2250
- onOpenChange={(open) => {
2251
- setInstructorEditSheetOpen(open);
2252
- if (!open) {
2253
- setEditingInstructorId(null);
2254
- }
2255
- }}
2256
- instructorId={editingInstructorId}
2257
- onSaved={async (instructor) => {
2258
- if (editingInstructorId === null && instructor?.id) {
2259
- const createdId = String(instructor.id);
2260
- const current = form.getValues('instrutores') ?? [];
2261
- if (!current.includes(createdId)) {
2262
- form.setValue('instrutores', [...current, createdId], {
2263
- shouldDirty: true,
2264
- shouldTouch: true,
2265
- shouldValidate: true,
2266
- });
2267
- }
2268
- }
2269
-
2270
- await refetchInstructorOptions();
2271
- await refetchCourse();
2272
- }}
2273
- />
2274
-
2275
2385
  <Sheet
2276
2386
  open={videoProfileEditSheetOpen}
2277
2387
  onOpenChange={(open) => {
@@ -2459,27 +2569,3 @@ export function EditorCourse() {
2459
2569
  </Form>
2460
2570
  );
2461
2571
  }
2462
-
2463
- // ── Helpers ───────────────────────────────────────────────────────────────────
2464
-
2465
- function StatChip({
2466
- icon,
2467
- label,
2468
- value,
2469
- }: {
2470
- icon: React.ReactNode;
2471
- label: string;
2472
- value: string | number;
2473
- }) {
2474
- return (
2475
- <div className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-2">
2476
- <span className="text-muted-foreground shrink-0">{icon}</span>
2477
- <div className="min-w-0">
2478
- <p className="text-[0.65rem] text-muted-foreground truncate">{label}</p>
2479
- <p className="text-sm font-semibold tabular-nums leading-none">
2480
- {value}
2481
- </p>
2482
- </div>
2483
- </div>
2484
- );
2485
- }